「Tello_Video」の改造

#はじめに
このページは，

[公式SDK「Tello-Python」を試そう](https://qiita.com/hsgucci/items/3327cc29ddf10a321f3c)

の１ページです．
全体を見たい場合は上記ページへお戻りください．

#概要

DJI公式のTello用Pythonサンプルプログラム「Tello-Python」のうち，

　[Tello_Video](https://github.com/dji-sdk/Tello-Python/tree/master/Tello_Video)

を改造して使う方法を説明します．

Tello-Pythonには４つのサンプルがありますが，それぞれの特徴を言えば以下のようになります．

- tello_state.py　UDP受信でステータスを見るだけ
- Single_Tello_Test　UDP送信でコマンドを送って動かすだけ
- Tello_Video　映像を受け取って表示し，コマンドを送って動かすこともできる
- Tello_Video_With_Pose_Recognition　機械学習がついたTello_Video

tello_state.pyとSingle_Tello_Testでは，Telloのカメラ映像を使うことができません．
一方，Tello_Video_With_Pose_Recognitionは，機械学習が重いので応用性に乏しいです．
Telloのカメラ映像が取れて，操縦もできるTello_Videoが一番バランスが良いと思います．

カメラ映像を画像処理して，結果をフィードバックして機体を動かす，という「自律移動ロボット」の様な動作をさせるためには，Tello_Videoを改造すると良いでしょう．

今回は，

**Telloから受信したカメラ画像にOpenCVで簡単な画像処理をかける**

という作業をやってみたいと思います．

#前提条件

ホームフォルダにTello-Pythonがインストールされているという前提で話を進めます．

Linuxマシンであれば　`/home/(ユーザー名)/`　に，`Tello-Python`というフォルダがあることになります．

詳しくは　[Tello-Pythonのダウンロード](https://qiita.com/hsgucci/items/3327cc29ddf10a321f3c#tello-python%E3%81%AE%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89)　を御覧ください．

#Tello_Videoのコピー

ちゃんと動く「Tello_Video」が必要です．
（つまり<font color="LightSeaGreen">**libh264decoder.so**</font>がビルド済みであること）

オリジナルのTello_Videoを書き換えてしまうと，プログラムが動かなくなってしまった時に大変なので，ディレクトリごとコピーして，コピー先を改変して行きましょう．

```bash:Tello_Videoをコピー
$ cp -R Tello_Video/ Tello_Video_PJ01/
```

`-r`または`-R`は，ディレクトリの中身も再帰的にコピーしてくれるオプションですね．

今後は，この<font color="blue">**Tello_Video_PJ01**</font>（プロジェクト01）を使います．

一応，PJ01の中身がちゃんとあるか確認しておきます．

```bash:Tello_Video_PJ01の中を確認
$ cd Tello_Video_PJ01
$ ls
LICENSE.md   img                main.py    tello_control_ui.py
README.md    install            tello.py   tello_control_ui.pyc
h264decoder  libh264decoder.so  tello.pyc
```

今回は，<font color="red">**tello_control_ui.py**</font>を書き換える話になります．

システム構成図から考えると，
![tello_video_system.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256470/3ed5da46-d735-168c-257f-14d51c943293.png)
「**え？ main.pyが実行している本体だから，main.pyをイジるんじゃないの？**」
と思ってしまいますが，そのあたりも含めて解説します．

#main.pyの解説

まずはmain.pyの中を見てみます．

```python:main.py(日本語コメント付き)
import tello    # tello.pyをインポート
from tello_control_ui import TelloUI    # tello_control_ui.pyをインポート

# メイン関数本体
def main():

    # Telloクラスを使って，droneというインスタンス(実体)を作る
    drone = tello.Tello('', 8889) 

    # TelloUIクラスを使って，vplayerというインスタンスを作る
    # 　上記のdroneと，スナップショット保存先フォルダを引数で渡す.
    # 　なので，droneに対する操作はvplayer(TelloUIクラス)の中で行っている
    vplayer = TelloUI(drone,"./img/")    
    
    # vplayerはTkinterを使ったGUIウィンドウプログラムなので，メインループを回す必要がある
    vplayer.root.mainloop() 

# "python main.py"として実行された時だけ動く様にするおまじない処理
if __name__ == "__main__":
    main()    # メイン関数を実行
```
コメント行を除けば，実質８行のプログラムで，とても短いです．

イメージとしては，

「**drone(Telloクラス)を作り，vplayer(TelloUIクラス)に作業を丸投げしている**」
「**main.pyはループを回すだけで，それ以外の仕事はしていない**」

という感じでしょうか．

###ループについて，ちょっと解説

ある単作業を行って終了して良いプログラムではなく，永続的に動き続ける必要があるプログラムでは，必ず何らかのループが回っています．

例えば**Arduino**のプログラミングだと`setup()`と`loop()`という関数がありますが，`loop()`は永続的に呼び出される関数ですね．
これは，main関数が１回だけ走って終了してしまっては困るからです．
電源が入っている限り，ずっと走り続ける必要があるからです．
もしループがなかったら，プログラムはすぐ終了し，CPUはやることがなくなって停止します．

```c:main関数の構造(イメージ)
void main() {
    setup();        // setupは1回だけ呼び出される

    while(1) {      // 永久ループ
        loop();     // loopは何度も呼び出される
    }
}
```

**Processing**でも，`setup()`と`draw()`があり，`draw()`は何度も呼び出されます．
これも，プログラムが終了せず，ずっとグラフィックを描画し続ける必要があるからです．

**Windows**でウィンドウを作るプログラミングでも，while文で書かれた「メッセージループ」が回っています．
これも，プログラムが終了せず，ボタンクリックやウィンドウ移動などのイベントメッセージが届くのを待ち続ける必要があるからです．
ループがなければプログラムはすぐ終了し，OSに制御を返してしまいます．

```c:Win32APIのmain関数のイメージ(省略あり)
int WINAPI WinMain(略)
{
    略
    hWnd = CreateWindow(略);
    ShowWindow(hWnd, nCmdShow);
    UpdateWindow(hWnd);
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
}
```

**MQTTのPythonライブラリであるpaho-mqtt**でも，`mqtt_client.loop_forever()`の様に呼び出して，永遠にループを回し続ける使い方があります．

同じ考え方で，Linuxでもウィンドウ(GUI)を作るプログラミングでは，ボタンクリックやウィンドウ移動などのイベントを待ち続ける必要があるので，どこかでループを回す必要があります．

`main.py`の場合は，それが`vplayer.root.mainloop()`です．
Tkinterというライブラリの中で暗黙的に定義されているループですが，これを呼ぶことでプログラムは終了せずに走り続けるのです．


#tello_control_ui.pyの解説と改造

`tello_control_ui.py`は360行あるので，全てを説明すると大変です．
大事なポイントとなる<font color="blue">**videoLoop関数**</font>に絞ります．

`videoLoop`とその次の`_updateGUIImage`を，日本語訳して適宜改変したものを以下に示します．

```python:videoLoop関数(tello_control_ui.py)
    def videoLoop(self):
        """
        ビデオを取得するスレッド
             (原文は「Tkinterのメインループスレッド」だったけど，なんか違う．
        """
        try:   # ランタイムエラー(主にH.264のデコード失敗)を引っ掛けるためのtry except文

            # 画像取得スレッドの開始
            time.sleep(0.5)    # 0.5秒のウェイト（おそらくTelloとの通信の安定を待つ)
            self.sending_command_thread.start()    # 5秒おきに'command'を送信するスレッドを開始
                                                   # Telloは15秒間コマンドが来ないと自動着陸してしまうので．

            while not self.stopEvent.is_set():    # スレッド終了命令を検知するまで永久ループ                
                system = platform.system()    # OSが何か調べる(Win?Mac?Linux?)

                # GUIで表示するフレームの取得
                self.frame = self.tello.read()    # Telloクラスで受信＆デコードした画像をここで読み取る
                if self.frame is None or self.frame.size == 0:    # 画像データの中身が空(要はデータの取得失敗)だったら
                    continue    # 無視してwhileループ先頭へ戻る
            
                # フレームをTkinter用にPILイメージに変換する        
                image = Image.fromarray(self.frame)

                # Mac OSではTkinterのImageTK.PhotoImage関数の処理に非常に長い時間がかかる問題があったため，
                # Mac OSの場合は_updateGUIImage関数を別スレッドとして実行する様にしました
                if system =="Windows" or system =="Linux":    # WindowsとLinuxでは普通に関数を呼ぶ                
                    self._updateGUIImage(image)    # imageをGUIウィンドウのパネルに描画する

                else:    # Macではスレッドとして呼ぶ
                    thread_tmp = threading.Thread(target=self._updateGUIImage,args=(image,))    # スレッド作成
                    thread_tmp.start()    # スレッド開始
                    time.sleep(0.03)    # 1フレーム分(30FPS)の待ち時間

        except RuntimeError, e:    # ランタイムエラーを検知したら
            print("[INFO] caught a RuntimeError")    # エラーメッセージを表示

    # ウィンドウの映像を更新(videoLoopから呼ばれる)
    def _updateGUIImage(self,image):
        """
        imageオブジェクトの初期化と，GUIのパネルの更新する
        """  
        image = ImageTk.PhotoImage(image)
        # パネルがなければ，初期化をする必要がある
        if self.panel is None:
            self.panel = tki.Label(image=image)
            self.panel.image = image
            self.panel.pack(side="left", padx=10, pady=10)
        # そうでなければ，単純にパネルを更新する
        else:
            self.panel.configure(image=image)
            self.panel.image = image
```

##videoLoopの解説

この`videoLoop`関数は，TelloUIのコンストラクタ(`__init__`)の中で，以下の様に呼びだされています．

```python:TelloUIクラスのコンストラクタの一部
        self.thread = threading.Thread(target=self.videoLoop, args=())
        self.thread.start()
```
新しいスレッドとして`videoLoop`を登録しているので，Tkinterのメインループと同時並行で走ることになります．

そのため，videoLoopを(分かりやすくtry文を排除して)見てみると

```python:videoLoopの冒頭
def videoLoop(self):

    # １回だけ実行される初期化処理 ≒ setup()
    time.sleep(0.5)    # 0.5秒のウェイト（おそらくTelloとの通信の安定を待つ)
    self.sending_command_thread.start()    # 5秒おきに'command'を送信するスレッドを開始

    # 永久に繰り返されるループ処理 ≒ loop()
    while not self.stopEvent.is_set():    # スレッド終了命令を検知するまで永久ループ                
        （略）
```
初期化処理とループ処理に別れていることが，分かります．

##画像を取り込む部分
videoLoop内のwhileループを見ていくと，

```python:Telloクラスから画像を取り込む部分
        # GUIで表示するフレームの取得
        self.frame = self.tello.read()    # Telloクラスで受信＆デコードした画像をここで読み取る
        if self.frame is None or self.frame.size == 0:    # 画像データの中身が空(要はデータの取得失敗)だったら
            continue    # 無視してwhileループ先頭へ戻る
```
<font color="blue">**self.tello.read()**</font>関数で，画像を読み込んでいることが分かります．
OpenCVで例えれば，

```python:OpenCVでビデオの取得する場合
cap = cv2.VideoCapture(0) #ビデオキャプチャの開始

while True:
    ret, frame = cap.read() #ビデオキャプチャから画像を取得
```
<font color="red">**cap.read()**</font>に該当することになります．

ただし，USBカメラやIEEEカメラから画像を取るOpenCVと異なり，WiFiのストリーミングで画像を取るTelloは，画像が欠けたりすることも多いので，
`self.frame is None or self.frame.size == 0`の様に中身がなかった場合の処理も書き足されています．


##ウィンドウへの表示

取り込んだ画像をウィンドウへ表示する部分を，WindowsやMacという機種依存部分を無視して書くと以下の様になります．

```python:画像フォーマットの変換と，Tkinterのウィンドウへの表示(パネルへ描画)
        # フレームをTkintery用にPILイメージに変換する        
        image = Image.fromarray(self.frame)

        self._updateGUIImage(image)    # imageをGUIウィンドウのパネルに描画する
```
Tkinterで作ったウィンドウに表示したいので，Tkinterに対応したPILという画像ライブラリのフォーマットに変換しています．


##videoLoopのフロー

前節の内容を図示して見ると，以下の様になります．
![videoLoop_system.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256470/7af0b310-51d9-00e6-adf1-3122da2ac857.png)

図中にも書いてある通り，
もしもOpenCVなどを使って画像処理をするのであれば，

- 画像読み込みの後　(そもそも画像がなければ画像処理できない)
- 画像のウィンドウ表示の前　(画像処理結果をウィンドウに表示したい)

に書くべきでしょう．
というわけで，次項では簡単な改造方法を示します．

#OpenCVの画像処理を追加

それではこれから，tello_control_ui.pyを書き換えて，比較的簡単なOpenCV関数を使って画像処理をかけてみましょう．

キーポイントは，

- `self.frame`にTello画像が入っている
- 最終的に`image`にPIL画像を格納しておけばウィンドウに表示される

です．
従って，次のプログラムのような流れを意識しておくと良いでしょう． 

```python:画像処理結果はcv_imageに格納して，それを後でPIL画像にする
        self.frame = self.tello.read()    # 画像を読み取る

        # ここに画像処理を書く
        cv_image = （self.frameを入力画像とし，処理結果をcv_imageに代入）

        image = Image.fromarray(cv_image)    # self.frameの代わりに，cv_imageをPIL画像へ変換
        self._updateGUIImage(image)    # imageをGUIウィンドウに描画する
```

入力画像 **self.frame** を画像処理した結果を， **cv_image** という新しい変数に入れる様に書きましょう．
そして，今まで **image = Image.fromarray(<font color="red">self.frame</font>)** だった文は，**image = Image.fromarray(<font color="blue">cv_image</font>)**になりました．

この入出力の辻褄さえ分かっていれば，後は色々なOpenCV関数を試すだけです．

##画像のサイズ変更(リサイズ)

TelloからHD(720p)で送られてくる画像は，4:3のアスペクト比なので 960 x 720ピクセルです．
これを表示させると，結構大きくて重たいことが多いです．

`resize`関数で，画像サイズを半分にしてみましょう．

　　[OpenCV - 画像をリサイズする方法 (cv2.resize)](http://pynote.hatenablog.com/entry/opencv-resize)



```python:画像サイズを変更する
        self.frame = self.tello.read()    # Telloクラスで受信＆デコードした画像をここで読み取る
        if self.frame is None or self.frame.size == 0:    # 画像データの中身が空(要はデータの取得失敗)だったら
            continue    # 無視してwhileループ先頭へ戻る

        # ここに画像処理を書き足す
        cv_image = cv2.resize( self.frame, dsize=(480, 360) )  # self.frameの画像を480x360にリサイズして，cv_imageに格納

        image = Image.fromarray(cv_image)    # self.frameの代わりに，cv_imageをPIL画像へ変換
```

(入力)self.frame → cv_image → image(出力)

と変換結果が引き継がれていくことがわかると思います．

**結果**
ウィンドウのサイズが小さくなりました．
![cv_resize.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256470/d02f3fa4-452a-f911-3333-bb07d21c3a30.png)

##画像のグレイスケール化

RGBの３色を使わず，白黒のグレイスケール画像で行う処理もあるので，書いておきます．

　　　[OpenCVにてRGB画像をグレースケールに変換する](https://newtechnologylifestyle.net/opencv%E3%81%AB%E3%81%A6rgb%E7%94%BB%E5%83%8F%E3%82%92%E3%82%B0%E3%83%AC%E3%83%BC%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%AB%E3%81%AB%E5%A4%89%E6%8F%9B%E3%81%99%E3%82%8B/)



```python:グレイスケールにする
        self.frame = self.tello.read()    # Telloクラスで受信＆デコードした画像をここで読み取る
        if self.frame is None or self.frame.size == 0:    # 画像データの中身が空(要はデータの取得失敗)だったら
            continue    # 無視してwhileループ先頭へ戻る

        # ここに画像処理を書き足す
        image1 = cv2.resize( self.frame, dsize=(480, 360) )  # self.frameの画像を480x360にリサイズして，image1に格納
        cv_image = cv2.cvtColor(image1, cv2.COLOR_BGR2GRAY)  # image1をグレイスケール画像にして，cv_imageに格納

        image = Image.fromarray(cv_image)    # self.frameの代わりに，cv_imageをPIL画像へ変換
```
2つの処理をかけるので，image1という変数が増えて

(入力)self.frame → image1 → cv_image → image(出力)

と引き継がれていることがわかります．

画面が小さくなった上に，白黒になりました．
![cv_gray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256470/4fb75ee8-46cd-6cca-a73c-a75baca89e69.png)



##画像の上下反転

Telloでは使うことはまずありませんが，ラズパイカメラだと設置の仕方によって使うことがあるので，書いてみました．

　　　[画像を反転する](https://tat-pytone.hatenablog.com/entry/2019/02/26/213449)

```python:画像サイズを変更する
        self.frame = self.tello.read()    # Telloクラスで受信＆デコードした画像をここで読み取る
        if self.frame is None or self.frame.size == 0:    # 画像データの中身が空(要はデータの取得失敗)だったら
            continue    # 無視してwhileループ先頭へ戻る

        # ここに画像処理を書き足す
        image1 = cv2.resize( self.frame, dsize=(480, 360) )  # self.frameの画像を480x360にリサイズして，image1に格納
        image2 = cv2.cvtColor(image1, cv2.COLOR_BGR2GRAY)  # image1をグレイスケール画像にして，image2に格納
        cv_image = cv2.flip(image2,0)   # image2を上下反転してcv_imageに格納

        image = Image.fromarray(cv_image)    # self.frameの代わりに，cv_imageをPIL画像へ変換
```
３つの処理をかけるので，更にimage2という変数が増え，

(入力)self.frame → image1 → image2 → cv_image → image(出力)

と引き継がれていくことがわかります．

更に上下逆さまになりました．
![cv_flip.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256470/e478aeb4-8967-158e-8638-051a581d0eba.png)


#おわりに

「Tello_Video」のGUIを司る「tello_control_ui.py」その中でもビデオ関連を担っている「videoLoop」を書き換えて，OpenCVの関数が使える事を確認しました．

videoLoopを書き換えるだけでも，結構色々な処理ができます．

しかし，Tkinterで作ったGUIでわざわざ映像を表示する，というのも無駄が多いですね．

OpenCVをやっている人であれば，
「<font color="blue">**cv2.imshow**</font>で処理結果をウィンドウに出すから，Tkinterのウィンドウなんかいらないよ．」
と言うかもしれません．

次の記事では，tello.pyだけ利用してOpenCVで映像表示させようと思います．