Jetson Nano + EdgeTPU で爆速PoseNet (ラズパイとのパフォーマンス比較)


## 【内容】
[以前の記事](https://qiita.com/rhene/items/01195365eb37d43d5ed6) でラズパイ+EdgeTPU+PoseNetを使って姿勢推定を試してみたところ、計算上は7～12FPSぐらい出ていましたが、出力に使われていたsvgwriteが遅くて実質1FPS程度しか出ていませんでした。
また、Jetson Nanoではどのぐらいのパフォーマンスが出るか試してみたかったのですが、サンプルプログラムのままでは動作させることが出来ませんでした。

そこで、パフォーマンスを改善を目指すとともにJetson Nanoでも動くようにサンプルプログラムを改修して、それそれでパフォーマンスを測定しました。

## 【前提条件】
本記事ではラズパイおよびJetson Nanoで、それぞれ下記記事の手順でEdgeTPUの環境構築が終わっているものとします。

ラズパイ：[【Coral USB TPU Accelerator(EdgeTPU)をとりあえず使う (Quick Start)】](https://qiita.com/rhene/items/4a55e9d428594092ee58)
Jetson Nano:[【Jetson Nano で EdgeTPU を使う】](https://qiita.com/rhene/items/3a0ec46d11d2687ecff0)



## 【PoseNet環境構築】
EdgeTPU状態でPoseNetのサンプルプログラムの取得と、環境構築を行います。
下記コマンドを実行してください。

```bash:PoseNet環境構築
cd ~/
git clone https://github.com/google-coral/project-posenet
cd project-posenet
sudo sh install_requirements.sh
```


## 【カメラ用サンプルプログラムの実行】
```bash:サンプルプログラム実行
python3 pose_camera.py
```

ラズパイでは実行可能でしたが、Jetson Nanoでは下記のエラーで実行できませんでした。

```bash:エラー
Loading model:  models/posenet_mobilenet_v1_075_481_641_quant_decoder_edgetpu.tflite
INFO: Initialized TensorFlow Lite runtime.
Gstreamer pipeline:  v4l2src device=/dev/video0 ! video/x-raw,width=640,height=480,framerate=30/1 ! queue max-size-buffers=1 leaky=downstream  ! videoconvert ! videoscale ! video/x-raw,format=RGB,width=640,height=480 ! tee name=t
               t. ! queue max-size-buffers=1 leaky=downstream ! appsink name=appsink sync=false emit-signals=true max-buffers=1 drop=true
               t. ! queue max-size-buffers=1 leaky=downstream ! identity ! videoconvert
                  ! rsvgoverlay name=overlay ! videoconvert ! autovideosink

Error: gst-stream-error-quark: Internal data stream error. (1): gstbasesrc.c(3055): gst_base_src_loop (): /GstPipeline:pipeline0/GstV4l2Src:v4l2src0:
streaming stopped, reason not-negotiated (-4)
```

## 【EdgeTPU + OpenCV + PoseNet】
gstreamerがよくわかっていないので、映像入力部分をJetson Nanoでも実行実績のあるOpenCVのVideoCaptureを使うように変更します。
また、出力に関してもsvgwriteからOpenCVに変更して、ラズパイにおけるパフォーマンスの向上を目指しました。

下記がそのソースコードです。
サンプルプログラムをベースに入力及び出力をOpenCVに置き換えています。
サンプルプログラムと同じフォルダ内に `pose_opencv.py` というファイル名で保存してください。

```python:pose_opencv.py
import argparse
from functools import partial
import re
import time
import os

import numpy as np
from PIL import Image
import cv2
# import svgwrite
# import gstreamer

from pose_engine import PoseEngine

EDGES = (
    ('nose', 'left eye'),
    ('nose', 'right eye'),
    ('nose', 'left ear'),
    ('nose', 'right ear'),
    ('left ear', 'left eye'),
    ('right ear', 'right eye'),
    ('left eye', 'right eye'),
    ('left shoulder', 'right shoulder'),
    ('left shoulder', 'left elbow'),
    ('left shoulder', 'left hip'),
    ('right shoulder', 'right elbow'),
    ('right shoulder', 'right hip'),
    ('left elbow', 'left wrist'),
    ('right elbow', 'right wrist'),
    ('left hip', 'right hip'),
    ('left hip', 'left knee'),
    ('right hip', 'right knee'),
    ('left knee', 'left ankle'),
    ('right knee', 'right ankle'),
)


def shadow_text(img, x, y, text, font_size=0.5):
    cv2.putText(img, text, (x, y), cv2.FONT_HERSHEY_SIMPLEX, font_size, (0,0,255), 1, cv2.LINE_AA)


def draw_pose(img, pose, threshold=0.2):
    xys = {}
    for label, keypoint in pose.keypoints.items():
        if keypoint.score < threshold: continue
        xys[label] = (int(keypoint.yx[1]), int(keypoint.yx[0]))
        img = cv2.circle(img, (int(keypoint.yx[1]), int(keypoint.yx[0])), 5, (0, 255, 0), -1)

    for a, b in EDGES:
        if a not in xys or b not in xys: continue
        ax, ay = xys[a]
        bx, by = xys[b]
        img = cv2.line(img, (ax, ay), (bx, by), (0, 255, 255), 2)

        
def main():
    parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    parser.add_argument('--mirror', help='flip video horizontally', action='store_true')
    parser.add_argument('--model', help='.tflite model path.', required=False)
    parser.add_argument('--res', help='Resolution', default='640x480',
                        choices=['480x360', '640x480', '1280x720'])
    parser.add_argument('--videosrc', help='Which video source to use (WebCam number or video file path)', default='0')
    # parser.add_argument('--h264', help='Use video/x-h264 input', action='store_true')
    args = parser.parse_args()

    default_model = 'models/posenet_mobilenet_v1_075_%d_%d_quant_decoder_edgetpu.tflite'
    if args.res == '480x360':
        src_size = (640, 480)
        appsink_size = (480, 360)
        model = args.model or default_model % (353, 481)
    elif args.res == '640x480':
        src_size = (640, 480)
        appsink_size = (640, 480)
        model = args.model or default_model % (481, 641)
    elif args.res == '1280x720':
        src_size = (1280, 720)
        appsink_size = (1280, 720)
        model = args.model or default_model % (721, 1281)

    print('Loading model: ', model)
    engine = PoseEngine(model, mirror=args.mirror)
    # engine = PoseEngine(model)

    
    last_time = time.monotonic()
    n = 0
    sum_fps = 0
    sum_process_time = 0
    sum_inference_time = 0
    
    width, height = src_size
    
    isVideoFile = False
    frameCount = 0
    maxFrames = 0

    # VideoCapture init
    videosrc = args.videosrc
    if videosrc.isdigit():
        videosrc = int(videosrc)
    else:
        isVideoFile = os.path.exists(videosrc)

    print("Start VideoCapture")
    cap = cv2.VideoCapture(videosrc)
    if cap.isOpened() == False:
        print('can\'t open video source \"%s\"' % str(videosrc))
        return;

    print("Open Video Source")
    cap.set(cv2.CAP_PROP_FPS, 60)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
    if isVideoFile:
        maxFrames = cap.get(cv2.CAP_PROP_FRAME_COUNT)

    try:
        while (True):
            ret, frame = cap.read()
            if ret == False:
                print('can\'t read video source')
                break;

            rgb = frame[:,:,::-1]
            
#             nonlocal n, sum_fps, sum_process_time, sum_inference_time, last_time
            start_time = time.monotonic()
            # image = Image.fromarray(rgb)
            outputs, inference_time = engine.DetectPosesInImage(rgb)
            end_time = time.monotonic()
            n += 1
            sum_fps += 1.0 / (end_time - last_time)
            sum_process_time += 1000 * (end_time - start_time) - inference_time
            sum_inference_time += inference_time
            last_time = end_time
            text_line = 'PoseNet: %.1fms Frame IO: %.2fms TrueFPS: %.2f Nposes %d' % (
                sum_inference_time / n, sum_process_time / n, sum_fps / n, len(outputs)
            )
            print(text_line)

            # crop image
            imgDisp = frame[0:appsink_size[1], 0:appsink_size[0]].copy()
            if args.mirror == True:
                imgDisp = cv2.flip(imgDisp, 1)

            shadow_text(imgDisp, 10, 20, text_line)
            for pose in outputs:
                draw_pose(imgDisp, pose)

            cv2.imshow('PoseNet - OpenCV', imgDisp)
            if cv2.waitKey(1) & 0xFF == ord('q'):
                break

            if isVideoFile:
                frameCount += 1
                # check frame count
                if frameCount >= maxFrames:
                    # rewind video file
                    cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
                    frameCount = 0


    except Exception as ex:
        raise ex
    finally:
        cv2.destroyAllWindows()
        cap.release()



if __name__ == '__main__':
    main()
```


## 【実行 (Webカメラ)】
下記コマンドで上記のプログラムを実行します。
`--mirror` オプションで左右を反転することが可能です。
また、デフォルトでは「0」番のWebカメラ(/dev/video0)が使われます。
`--videosrc` で入力ソースを変更することも可能です。

```bash:pose_opencvの実行
python3 pose_opencv.py

python3 pose_opencv.py --mirror

python3 pose_opencv.py --videosrc 1 
```

`--res` オプションで解像度を変更できます。
PoseNetは解像度に応じて'480x360', '640x480', '1280x720'の3種類のモデルが用意されています。

```
python3 pose_opencv.py --res <解像度>
```

## 【実行結果 (Webカメラ)】
|解像度   | Platform               |推論時間 (ms) |FrameIO (ms) |FPS     |備考                |
|:------:|:-----------------------|------------:|-------------:|-------:|:-------------------|
|1280x720| RaspberryPi3 + EdgeTPU |       279.4 |        75.82 |   2.47 |                    |
|-       | JetsonNano   + EdgeTPU |        49.2 |        23.82 |   7.44 |                    |
|640x480 | RaspberryPi3 + EdgeTPU |        94.4 |        27.48 |   7.16 |                    |
|-       | JetsonNano   + EdgeTPU |        15.0 |         8.53 |  29.89 | カメラの性能限界    |
|480x360 | RaspberryPi3 + EdgeTPU |        53.5 |        12.34 |  12.57 |                    |
|-       | JetsonNano   + EdgeTPU |         9.7 |         4.21 |  30.06 | カメラの性能限界    |

思ったよりも速いです。
Jetson Nanoの場合、解像度「640x480」で約30FPS出ています。
ほぼリアルタイムで処理できていますが、若干(1～2フレーム程度?)遅延は発生します。

また、解像度を「480x360」まで落とすとラズパイでも12FPS以上出るため、そこそこ見れる絵になります。
ただ、残念ながら手持ちのWebカメラが30FPSまでしか出ないので、Jetson Nano側はこれ以上の性能改善は見込めません。

流石に解像度「1280x720」では処理がおもすぎますか…


## 【実行 (ビデオファイル)】
cv2.VideoCaptureはビデオファイルを入力ソースにすることができるので、これを試してみます。
本プログラムでは `--videosrc` にファイルパスを渡すことで実現できます。
なお、サンプルビデオとしてOpenCVのリポジトリに含まれているデータを利用しました。

```bash:サンプルビデオの取得
wget https://github.com/opencv/opencv/raw/master/samples/data/vtest.avi
```
上記のビデオファイルの解像度が768x576なので、「640x480」と「480x360」でしか実行していません。

```bash:ビデオファイルテスト
python3 pose_opencv.py --videosrc ./vtest.avi --res 640x480

python3 pose_opencv.py --videosrc ./vtest.avi --res 480x360
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/454012/eba81364-a31d-181d-0ac4-1b4b9fb57982.png)


## 【結果 (ビデオファイル)】
|解像度   | Platform               |推論時間 (ms) |FrameIO (ms) |FPS     |備考                |
|:------:|:-----------------------|------------:|-------------:|-------:|:-------------------|
|640x480 | RaspberryPi3 + EdgeTPU |        65.3 |        19.59 |   8.68 |                    |
|-       | JetsonNano   + EdgeTPU |        15.0 |         6.87 |  30.08 |                    |
|480x360 | RaspberryPi3 + EdgeTPU |        33.9 |        10.27 |  14.64 |                    |
|-       | JetsonNano   + EdgeTPU |         8.9 |         4.00 |  45.57 |                    |

## 【最後に】
本体性能による差が如実に現れていると思います。
とくにラズパイのUSBが2.0ということで、本体とEdgeTPUの通信がボトルネックになっていそうです。
ラズパイ4が入手できる日が来ることを切に願います。

なお、お盆休みの終わり頃には新しいエッジデバイスが入手できる予定なので、それを入手できたらまたパフォーマンスを比較してみたいと思います。