Voice Kit を Android Things で動かしてみる

f:id:akanuma-hiroaki:20180308074945p:plain:right

 Voice Kit は標準では Raspbian で動かしますが、 Android Things を使うこともできるということなので、試しに動かしてみました。 Voice Kit のページから Android Things のチュートリアルへのリンクが張られています。

*1


aiyprojects.withgoogle.com

 Android Things とはスマートフォンだけでなく IoT デバイスでも Android が動くように最適化されたもので、全てではないですが Android の API や SDK を使うことができます。逆に Android Things の API には各種センサーからデータを取得したり、ハードウェアを操作したりするための API が追加されていますので、 Android アプリ開発ができる方であれば同じスキルで IoT デバイスの開発ができそうです。ちなみに Android Things は現在まだ Developer Preview です。

developer.android.com

 Voice Kit を Android Things で動かすチュートリアルは下記で公開されています。

Android Things Assistant

Android Studio をインストール/アップデート

 Android アプリ開発には Android Studio を使いますが、 Android Things でも同様に Android Studio を使いますのでインストールします。 Android Things には Android Studio の 3.0 以上が必要なので、古いバージョンを使っている場合はアップデートします。私は 2系 だったのでアップデートしました。

developer.android.com

Android SDK をアップデート

 SDK も古い場合にはアップデートが必要です。 SDK Tools は 25.0.3 以上、 SDK Platforms は Android 7.0(API24)以上が必要となりますので、 SDK Manager でアップデートします。

Update the IDE and SDK Tools | Android Studio

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

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

Android Things イメージの書き込み

 次に Android Things のイメージを microSDカードに書き込みます。 手順は下記にも公開されています。

Raspberry Pi 3 | Android Things

 Android Things ではイメージのダウンロードや書き込みを行ってくれるセットアップツールが用意されているので、まずはそちらをダウンロードするために Android Things コンソールにアクセスします。初めてアクセスした時は規約への同意を求められるので、チェックボックスにチェックを入れて CONTINUE をクリックします。

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

 コンソールが表示されたら左上のメニューから Tools をクリックします。

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

 Setup Utility の説明が表示されますので、 DOWNLOAD ボタンをクリックしてツールをダウンロードします。

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

 ダウンロードしたツールを解凍すると、下記のように各プラットフォーム向けのツールが用意されています。

$ ls -l Downloads/android-things-setup-utility/
total 40024
-rw-r--r--@ 1 akanuma  staff     1721 Jan 26 03:55 README.md
-rwxr-xr-x@ 1 akanuma  staff  6339237 Jan 26 03:54 android-things-setup-utility-linux
-rwxr-xr-x@ 1 akanuma  staff  7687540 Jan 26 03:54 android-things-setup-utility-macos
-rwxr-xr-x@ 1 akanuma  staff  6456832 Jan 26 03:54 android-things-setup-utility-windows.exe

 Macの場合は下記のようにファイル名末尾が macos になっているものを実行します。

$ sudo ~/Downloads/android-things-setup-utility/android-things-setup-utility-macos 

Android Things Setup Utility (version 1.0.18)
============================
This tool will help you install Android Things on your board and set up Wi-Fi.

 対話形式で設定を選択していきます。まずは新たに Android Things をインストールするか、既存の Android Things デバイスの Wi-Fi をセットアップするか聞かれるので、前者(1)を選択します。

What do you want to do?
1 - Install Android Things and optionally set up Wi-Fi
2 - Set up Wi-Fi on an existing Android Things device
1

 次にハードウェアの選択です。 Voice Kit の場合は Raspberry Pi なので、1 の Raspberry Pi 3 を選択します。すると必要なツールのダウンロードと解凍が行われます。

What hardware are you using?
1 - Raspberry Pi 3
2 - NXP Pico i.MX7D
3 - NXP Pico i.MX6UL
1
You chose Raspberry Pi 3.

Setting up required tools...
Fetching additional configuration...
Downloading platform tools...
5.45 MB/5.45 MB
Unzipping platform tools...
Finished setting up required tools.

 次にデフォルトのイメージかカスタムイメージを使うか聞かれます。初めて使う場合はデフォルトイメージを選択します。するとイメージのダウンロードと解凍が行われ、さらに SD カードにイメージを書き込むためのツールのダウンロードと解凍が行われます。

Do you want to use the default image or a custom image?
1 - Default image: Used for development purposes. No access to the Android
Things Console features such as metrics, crash reports, and OTA updates.
2 - Custom image: Provide your own image, enter the path to an image generated
and from the Android Things Console.
1
Downloading Android Things image...
274 MB/274 MB 
Unzipping image...

Downloading Etcher-cli, a tool to flash your SD card...
20.5 MB/20.5 MB
Unzipping Etcher-cli...

 ここまででイメージの準備ができました。SDカードを Mac のSDカードスロットに入れて Enter キーを押すように促されますので、イメージを書き込む microSD カードをスロットに入れて Enter を押します。

Plug the SD card into your computer. Press [Enter] when ready

Running Etcher-cli...

 ドライブの選択プロンプトが表示されます。複数の SD カードスロットがある場合は複数のドライブが表示されるかと思いますが、一つの場合はそのまま Enter を押します。

? Select drive /dev/disk2 (32.0 GB) - SD Card Reader

 選択したドライブの SD カードの内容は消去されますので、問題なければ Yes を入力して Enter します。すると実際に書き込みが行われます。

? This will erase the selected drive. Are you sure? Yes
Flashing [========================] 100% eta 0s  
Validating [========================] 100% eta 0s  
iot_rpi3.img was successfully written to SD Card Reader (/dev/disk2)
Checksum: c1891a15

If you have successfully installed Android Things on your SD card, you can now
put the SD card into the Raspberry Pi and power it up. Otherwise you can abort
and run the tool again.

 書き込みが終わると、続けて Wi-Fi のセットアップを行うか聞かれます。行う場合は y を選択します。

Would you like to set up Wi-Fi on this device? (y/n) y

 ここまで来たら Mac から microSD カードを抜いて Voice Kit に挿し、 LAN ケーブルで LAN に接続して起動します。起動したら Enter を押すと、 Mac から LAN 内の Android Things(Voice Kit)に接続し、 Wi-Fi のセットアップを続行します。

Please plug your Raspberry Pi to your router with an Ethernet cable, then press [Enter].


Attempting to connect to your Raspberry Pi at Android.local...
Connected to device through Ethernet.

 正しく接続されると接続先APの SSID とパスワードを聞かれるので入力します。Wi-Fi セットアップが成功すれば一通りセットアップは完了ですので、 Enter を押してセットアップツールを終了します。

Enter the Wi-Fi network name: xxxxxxxxxxxxxx
Enter the Wi-Fi network password (leave empty if no password): 
Connecting to Wi-Fi network xxxxxxxxxxxxxx...
Looking for device... This can take up to 3 minutes.
Device found.
Waiting...
Successfully connected to Wifi
Stopping adb server...
Stopped adb server...

Now that you’re set up, try sample projects in Android Studio or in the sample
repository here: https://developer.android.com/things/sdk/samples.html

To learn more about features like over-the-air updates, visit the Android Things
Console: https://partner.android.com/things/console

Press [Enter] to quit.

 セットアップが終了したら一度 Voice Kit を再起動しておくのが良いかと思います。

adb での接続

 adb で Voice Kit に接続します。 IP アドレス指定でも接続できますが、マルチキャストDNSで Android.local というホスト名でも接続することができるようになっています。

$ adb connect Android.local
connected to Android.local:5555

サンプルコードの取得

 チュートリアルではサンプルコードが提供されていますので、今回はとりあえずそれを動かして見ます。 github から git clone で取得します。

$ git clone https://github.com/googlecodelabs/androidthings-googleassistant.git
Cloning into 'androidthings-googleassistant'...
remote: Counting objects: 338, done.
remote: Total 338 (delta 0), reused 0 (delta 0), pack-reused 338
Receiving objects: 100% (338/338), 117.37 KiB | 0 bytes/s, done.
Resolving deltas: 100% (101/101), done.
Checking connectivity... done.

認証情報の取得

 Google Assistant の API を使うためには API の有効化と認証情報の取得が必要になります。チュートリアル内でも紹介されていますが、私が最初に Voice Kit を試した時のサンプルコード実行時にも同様のことを行なっていますのでご参照ください。 assistant.json というファイル名で認証情報を保存しておきます。

blog.akanumahiroaki.com

認証実行

 まずは認証用のツールを pip でインストールします。

$ pip install google-auth-oauthlib[tool]

 続いてツールを使って認証を行います。 --client-secrets オプションには先ほど保存した認証情報のファイルパスを指定します。

$ google-oauthlib-tool --client-secrets assistant.json \
>                        --credentials shared/src/main/res/raw/credentials.json \
>                        --scope https://www.googleapis.com/auth/assistant-sdk-prototype \
>                        --save --headless
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=XXXXXXXXXXXX-v9m9nb4bgdp58qk159cghm1ketnvin5t.apps.googleusercontent.com&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fassistant-sdk-prototype&state=QBeuos2rtESJnJ9TEjX6sp1xaAFKaV&prompt=consent&access_type=offline

 表示されたURLにブラウザからアクセスし、表示される認証コードをコピーして入力します。正しく認証されれば credentials.json というファイルが作成されます。

Enter the authorization code: 4/AAAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
credentials saved: shared/src/main/res/raw/credentials.json

サンプルコードの実行

 git clone したサンプルコード androidthings-googleassistant を Android Studio にインポートします。ライブラリの不足がなければ下記のように Gradle でのビルドが行われます。

21:20    Gradle sync started
21:21   Project setup started
21:21   Gradle sync finished in 45s 764ms
21:21   Executing tasks: [:shared:generateDebugSources, :shared:generateDebugAndroidTestSources, :shared:mockableAndroidJar, :step1-start-here:generateDebugSources, :step1-start-here:generateDebugAndroidTestSources, :step1-start-here:mockableAndroidJar, :step2-volume-control:generateDebugSources, :step2-volume-control:generateDebugAndroidTestSources, :step2-volume-control:mockableAndroidJar, :step3-conversational-state:generateDebugSources, :step3-conversational-state:generateDebugAndroidTestSources, :step3-conversational-state:mockableAndroidJar]
21:21   Gradle build finished in 22s 828ms

 ビルドが終わったらサンプルコードを実行してみます。 step1, 2, 3 が用意されていますが、今回はとりあえず step1 を実行してみます。 step1 は Google Assitant の基本的なデモです。 Android Studio の Run メニューから Run 'step1-start-here' を選択します。選択肢にないときは Run... から選択します。

f:id:akanuma-hiroaki:20180307083756p:plain:w300

 対象のデバイスとして Voice Kit を選択します。デバイス名は Google Iot_rpi3 となっています。

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

 私の場合は初回実行時は下記のようにエラーになってしまいました。

03/04 21:32:50: Launching step1-start-here
$ adb shell am start -n "com.example.androidthings.assistant/com.example.androidthings.assistant.AssistantActivity" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER
Client not ready yet..Waiting for process to come online
Connected to process 4115 on device google-iot_rpi3-Android.local:5555
Capturing and displaying logcat messages from application. This behavior can be disabled in the "Logcat output" section of the "Debugger" settings page.
I/InstantRun: starting instant run server: is main process
I/AssistantActivity: starting assistant demo
D/AssistantActivity: enumerating devices
I/AssistantActivity: initializing DAC trigger
I/AssistantActivity: setting volume to: 15
D/AudioTrack: Client defaulted notificationFrames to 460 for frameCount 1380
E/AudioRecord: AudioFlinger could not create record track, status: -1
E/AudioRecord-JNI: Error creating AudioRecord instance: initialization check failed with status -1.
E/android.media.AudioRecord: Error code -20 when initializing native AudioRecord object.
D/AndroidRuntime: Shutting down VM
E/AndroidRuntime: FATAL EXCEPTION: main
                  Process: com.example.androidthings.assistant, PID: 4115
                  java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.androidthings.assistant/com.example.androidthings.assistant.AssistantActivity}: java.lang.UnsupportedOperationException: Cannot create AudioRecord
                      at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2778)
                      at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2856)
                      at android.app.ActivityThread.-wrap11(Unknown Source:0)
                      at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1589)
                      at android.os.Handler.dispatchMessage(Handler.java:106)
                      at android.os.Looper.loop(Looper.java:164)
                      at android.app.ActivityThread.main(ActivityThread.java:6494)
                      at java.lang.reflect.Method.invoke(Native Method)
                      at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438)
                      at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:807)
                   Caused by: java.lang.UnsupportedOperationException: Cannot create AudioRecord
                      at android.media.AudioRecord$Builder.build(AudioRecord.java:626)
                      at com.example.androidthings.assistant.AssistantActivity.onCreate(AssistantActivity.java:342)
                      at android.app.Activity.performCreate(Activity.java:7000)
                      at android.app.Activity.performCreate(Activity.java:6991)
                      at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1214)
                      at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2731)
                      at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2856) 
                      at android.app.ActivityThread.-wrap11(Unknown Source:0) 
                      at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1589) 
                      at android.os.Handler.dispatchMessage(Handler.java:106) 
                      at android.os.Looper.loop(Looper.java:164) 
                      at android.app.ActivityThread.main(ActivityThread.java:6494) 
                      at java.lang.reflect.Method.invoke(Native Method) 
                      at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:438) 
                      at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:807) 
I/Process: Sending signal. PID: 4115 SIG: 9
Application terminated.

 少し調べたところ、セットアップ後は一度 Voice Kit を再起動する必要があるようなので、再起動後に再度実行すると下記のようにエラーなく実行できました。

03/04 21:37:33: Launching step1-start-here
$ adb install-multiple -r -t /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/dep/dependencies.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_1.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_7.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_6.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_8.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_9.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_4.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_5.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_0.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_3.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/intermediates/split-apk/debug/slices/slice_2.apk /Users/akanuma/workspace/androidthings-googleassistant/step1-start-here/build/outputs/apk/debug/step1-start-here-debug.apk 
Split APKs installed
$ adb shell am start -n "com.example.androidthings.assistant/com.example.androidthings.assistant.AssistantActivity" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER
Client not ready yet..Waiting for process to come online
Waiting for process to come online
Connected to process 1366 on device google-iot_rpi3-Android.local:5555
Capturing and displaying logcat messages from application. This behavior can be disabled in the "Logcat output" section of the "Debugger" settings page.
I/AssistantActivity: starting assistant demo
D/AssistantActivity: enumerating devices
I/AssistantActivity: initializing DAC trigger
I/AssistantActivity: setting volume to: 15
D/AudioTrack: Client defaulted notificationFrames to 460 for frameCount 1380
D/NetworkSecurityConfig: No Network Security Config specified, using platform default
D/vndksupport: Loading /vendor/lib/hw/android.hardware.graphics.mapper@2.0-impl.so from current namespace instead of sphal namespace.

 ここまで行けば Google Assistant を試すことができるようになっています。このサンプルでは Voice Kit のボタンを押している間は発話を受け付けてくれますので、ボタンを押したまま "What time is it now?" などと話してボタンを離すと、 Google Assistant が回答してくれます。

まとめ

 今回はとりあえずサンプルコードを一つ動かしてみただけですが、最初のセットアップさえ終わってしまえばコードを実行するときもスマートフォンアプリを開発するときと同じように Android Studio から動かすことができますので、 Android アプリ開発が得意な人には良さそうです。実際に作り込んでいった時にどこまで API が対応しているのかはまだわかってませんが、今後正式版になれば良い選択肢の一つになるかもしれません。

*1:Android ロボットは、Google が作成および提供している作品から複製または変更したものであり、Creative Commons 3.0 Attribution ライセンスに記載された条件に従って使用しています。

Voice Kit + Dialogflow で来客受付システム

 前回は Voice Kit と Web カメラで簡単な対話を行う処理を実装してみましたが、単語での回答に対してテキストマッチングするだけのものだったので、今回はもっと対話的な処理を行えるよう、 Dialogflow と組み合わせて会社の受付システムを想定したものを実装してみたいと思います。

 実装する処理としては、Webカメラで来客を検知したら、名前と社名、誰とのアポイントメントか、人数は何人かを聞いて、 Slack で画像をポストするというものです。

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

Dialogflow API 利用設定

 Dialogflow の API を初めて使う時は、利用を許可するための設定が必要になります。 GCP のコンソールのメニューから APIとサービス をクリックします。

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

 次に画面上部の API とサービスの有効化 をクリックします。

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

 API のリストの中から Dialogflow を検索してクリックします。

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

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

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

 次に認証キーを作成する必要がありますので、メニューから 認証情報 をクリックします。

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

 認証情報を作成 プルダウンから、 サービスアカウントキー を選択します。

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

 下記のスクリーンショットのように情報を入力して 作成 をクリックします。すると認証情報の json ファイルがダウンロードされますので、ダウンロードして Voice Kit(Raspberry Pi)上に配置しておきます。

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

 以上で Dialogflow の API の有効化は完了です。

Dialogflow API の Python クライアント インストール

 今回は Dialogflow の API を Python スクリプトから使用します。 Python 用のクライアントが提供されていますので、こちらをインストールします。ちなみに Dialogflow の API には Version 1 と 2 があり、今回は 2 を使用します。

github.com

 ドキュメントはこちらにあります。

Dialogflow: Python Client — dialogflow 0.1.0 documentation

 AIY プロジェクトのスクリプト実行用のシェルを起動して、 pip でインストールします。

$ bin/AIY-projects-shell.sh 
$ pip3 install dialogflow

Dialogflow の Agent 作成

 それでは発話内容に対応して対話を行うための Dialogflow の設定をします。 Dialogflowコンソールから Agent を新規に作成します。作成した Agent の情報は下記のようになります。 API の Version は 2 を使うように設定しておきます。

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

 次に各発話に対応する Intent を作成していきます。今回はひとまず最低限の内容を実装して動作させてみたいと思います。まずは WelcomeIntent は下記のように設定します。単純に来訪者に名前を言ってもらえるように促しているだけになります。

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

 名前と社名を言ってもらった場合に対応するための NameIntent を作成します。実際は様々なパターンの発話を考慮すべきですが、今回はとりあえず1パターンだけ用意してみます。ゲストの名前と会社名は対話後にも使用したいので Output Context にパラメータを追加して、他の Intent と共有されるようにしておきます。そしてレスポンスで誰とのアポイントメントなのかを言ってもらえるように促します。

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

 誰とのアポイントメントなのかを言ってもらった場合に対応するための AppointmentIntent を作成します。こちらもとりあえず1パターンの発話だけ対応させておきます。 Output Context には訪問先の社員名を保存しておきます。

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

 最後に人数は何名かを言ってもらった場合の PeopleCountIntent を作成します。人数情報は Output Context に保存します。そしてこの Intent のレスポンスで対話は終了する想定なので、 Set this intent as end of confersation にチェックを入れておきます。

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

 以上が最低限の Intent の実装となります。

Voice Kit 上の Python スクリプトの実装

 上記で作成した Dialogflow の Agent に対応する Python スクリプトを実装します。まずはコード全体を掲載しておきます。

#!/usr/bin/env python3

import sys
import uuid
import cv2
import dialogflow
import aiy.audio
import aiy.cloudspeech
import aiy.voicehat
from slackclient import SlackClient

class Receptionist:
    def __init__(self):
        self.project_id = 'xxxxxx-xxxx-xxxxxx'
        self.client_access_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
        self.language_code = 'en'
        self.session_client = dialogflow.SessionsClient()
        self.final_intent_name = 'PeopleCountIntent'

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

        self.recognizer = aiy.cloudspeech.get_recognizer()
        aiy.audio.get_recorder().start()

        self.status_ui = aiy.voicehat.get_status_ui()

        slack_token = 'xxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxx-xxxxxxxxxx'
        self.slack_client = SlackClient(slack_token)

    def send_welcome_request(self, session):
        event_input = dialogflow.types.EventInput(name = 'WELCOME', language_code = self.language_code)
        query_input = dialogflow.types.QueryInput(event = event_input)
        return self.send_request(session, query_input)

    def send_text_request(self, session, text):
        text_input = dialogflow.types.TextInput(text = text, language_code = self.language_code)
        query_input = dialogflow.types.QueryInput(text = text_input)
        return self.send_request(session, query_input)

    def send_request(self, session, query_input):
        response = self.session_client.detect_intent(session = session, query_input = query_input)

        print('=' * 20)
        print('Query text: {}'.format(response.query_result.query_text))
        print('Detected intent: {} (confidence: {})'.format(
            response.query_result.intent.display_name,
            response.query_result.intent_detection_confidence))
        print('Fulfillment text: {}'.format(response.query_result.fulfillment_text))
        print('-' * 20)

        return response

    def detect_face(self, cap):
        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:
                cv2.imwrite(self.guest_image_file, frame)
                break

    def post_to_slack(self, response):
        output_contexts = response.query_result.output_contexts[0]
        guest_name = output_contexts.parameters['guest_name']
        company_name = output_contexts.parameters['company_name']
        dest_name = output_contexts.parameters['dest_name']
        people_count = output_contexts.parameters['people_count']
        comment = '%s from %s is coming for %s with %d people.' % (guest_name, company_name, dest_name, int(people_count))

        with open(self.guest_image_file, 'rb') as guest_image:
            self.slack_client.api_call(
                'files.upload',
                channels = '#akanuma_private',
                file = guest_image,
                filetype = 'jpg',
                filename = self.guest_image_file,
                initial_comment = comment
            )


    def main(self):
        while(True):
            cap = cv2.VideoCapture(0)
            self.detect_face(cap)

            session_id = uuid.uuid4().hex
            session = self.session_client.session_path(self.project_id, session_id)
            response = self.send_welcome_request(session)
            aiy.audio.say(response.query_result.fulfillment_text)

            while(True):
                self.status_ui.status('listening')
                text = self.recognizer.recognize()
                if not text:
                    aiy.audio.say('Sorry, I did not hear you.')
                    continue

                print('You said: %s' % text)
                response = self.send_text_request(session, text)
                aiy.audio.say(response.query_result.fulfillment_text)

                if response.query_result.intent.display_name == self.final_intent_name:
                    break

            self.post_to_slack(response)

            cv2.waitKey(3000)
            cap.release()

if __name__ == '__main__':
    receptionist = Receptionist()
    receptionist.main()

 まずコンストラクタでは各種トークン情報の保持や Dialogflow クライアントのインスタンス、顔認識用の検出器のインスタンス、 Slack のクライアントのインスタンスの作成などをしています。本当は各種トークンはコード内にハードコードすべきではありませんが、今回はとりあえず簡単に試すためにハードコードしてしまっています。

def __init__(self):
    self.project_id = 'xxxxxx-xxxx-xxxxxx'
    self.client_access_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    self.language_code = 'en'
    self.session_client = dialogflow.SessionsClient()
    self.final_intent_name = 'PeopleCountIntent'

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

    self.recognizer = aiy.cloudspeech.get_recognizer()
    aiy.audio.get_recorder().start()

    self.status_ui = aiy.voicehat.get_status_ui()

    slack_token = 'xxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxx-xxxxxxxxxx'
    self.slack_client = SlackClient(slack_token)

 以降は main() メソッドの処理の順番に沿って説明します。まずは顔検出処理を実行し、受付にゲストが来て顔が検出されたらその時の画像を保存して、後続の処理に移ります。顔検出処理の内容については前回までとほぼ同様ですので、詳細は割愛します。

def detect_face(self, cap):
    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:
            cv2.imwrite(self.guest_image_file, frame)
            break

 顔が検出されたら対話処理を開始するために、 Dialogflow の WelcomeIntent へのリクエストを送信します。 WelcomeIntent のトリガーは他の Intent と違って発話ではないので、 WELCOME イベントで処理を開始するため、 EventInput のインスタンスを作成し、 QueryInput に渡します。

def send_welcome_request(self, session):
    event_input = dialogflow.types.EventInput(name = 'WELCOME', language_code = self.language_code)
    query_input = dialogflow.types.QueryInput(event = event_input)
    return self.send_request(session, query_input)

 そして Query の内容で Intent を判定する detect_intent() メソッドを実行してレスポンスを取得します。

def send_request(self, session, query_input):
    response = self.session_client.detect_intent(session = session, query_input = query_input)

 発話に応じたリクエストを投げる場合は EventInput の代わりに TextInput を使用します。それ以外の流れはイベントをトリガーする場合と同様です。

def send_text_request(self, session, text):
    text_input = dialogflow.types.TextInput(text = text, language_code = self.language_code)
    query_input = dialogflow.types.QueryInput(text = text_input)
    return self.send_request(session, query_input)

 発話に対応する処理は複数回になるため、ゲストからの発話を受け取ったら CloudSpeech API によってテキスト化し、 Dialogflow へのリクエスト送信、レスポンスとして受け取ったメッセージを発話するという流れをループで繰り返します。対話の終了判定は処理を返した Intent が最後の Intent (今回は PeopleCountIntent) だったらループを抜けるという処理にしています。対話の終了はレスポンスからもっとスマートに判断できないものかと思ったのですが、今回は判定方法がみつけられませんでした。

while(True):
    self.status_ui.status('listening')
    text = self.recognizer.recognize()
    if not text:
        aiy.audio.say('Sorry, I did not hear you.')
        continue

    print('You said: %s' % text)
    response = self.send_text_request(session, text)
    aiy.audio.say(response.query_result.fulfillment_text)

    if response.query_result.intent.display_name == self.final_intent_name:
        break

 対話が終了したらレスポンスに含まれる Output Context からゲストの名前、会社名、訪問先社員名、人数の情報を取得し、 Slack にポストするメッセージを作成します。そして顔検出時に保存しておいた画像をメッセージとともに Slack にアップロードします。

def post_to_slack(self, response):
    output_contexts = response.query_result.output_contexts[0]
    guest_name = output_contexts.parameters['guest_name']
    company_name = output_contexts.parameters['company_name']
    dest_name = output_contexts.parameters['dest_name']
    people_count = output_contexts.parameters['people_count']
    comment = '%s from %s is coming for %s with %d people.' % (guest_name, company_name, dest_name, int(people_count))

    with open(self.guest_image_file, 'rb') as guest_image:
        self.slack_client.api_call(
            'files.upload',
            channels = '#akanuma_private',
            file = guest_image,
            filetype = 'jpg',
            filename = self.guest_image_file,
            initial_comment = comment
        )

 Output Context についてはこちらに説明があります。

Types for Dialogflow API Client — dialogflow 0.1.0 documentation

 また、今回 Slack への Post にはこちらを使用しています。

github.com

スクリプトの実行

 ここまでで一通りの準備が終わったので、スクリプトを実行してみます。 GCP の API を使用するには認証を通す必要があります。認証については下記に説明があります。

Getting Started with Authentication  |  Documentation  |  Google Cloud Platform

 認証情報を含む json ファイルのパスを環境変数 GOOGLE_APPLICATION_CREDENTIALS に設定しておくことで認証情報が参照されますので、今回の冒頭でダウンロードした認証情報の json ファイルのパスを指定しておきます。

$ export GOOGLE_APPLICATION_CREDENTIALS="/home/pi/AIY-projects-python/src/examples/voice/service-account-file.json"

 そして下記のようにスクリプトを実行します。

$ src/examples/voice/receptionist.py

 実行した様子は下記の動画のようになります。ひとまず顔検出をトリガーにして想定した対話処理が行われているようです。

 また、 Slack には下記のように画像がアップロードされました。

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

まとめ

 今回は Dialogflow 側は最低限の設定しかしていないので、ほぼ決まった形での発話にしか対応できていませんが、 Dialogflow 側を作り込むことで様々な発話に柔軟に対応して対話することができるようになるかと思います。Slack とも連携できたので実用度もそれなりにありそうな気はしていますが、考えてみるとだいたい同じようなことはスマートフォンアプリでもできてしまうように思いますので、実際にはやはり Voice Kit のベースの Raspberry Pi の GPIO 等を活かした他のセンサーデバイス等との連携によって差別化できるのかなと思いました。とりあえず実験的に色々触ってみるのは面白いので、もう少し作り込めたら実際に会社の受付で試してみられると良いなと思っています。

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 の出力のコントロールだけでしたが、情報を入力するセンサーデバイスも使ってもう少し遊んでみたいと思います。