Raspberry Piのカメラモジュールで植物の成長を記録してタイムラプス動画にしてみた

最近、植物の[再生栽培](https://matome.naver.jp/odai/2139417104530897901)をはじめたので、成長の様子を写真に撮って最終的にタイムラプス動画にしてみました

<blockquote class="twitter-tweet" data-lang="ja"><p lang="und" dir="ltr"><a href="https://t.co/R0h5RFbJVc">pic.twitter.com/R0h5RFbJVc</a></p>&mdash; siwasu17 (@siwasu17) <a href="https://twitter.com/siwasu17/status/947818237596655617?ref_src=twsrc%5Etfw">2018年1月1日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


# 使うもの
* お好きな植物
    * 今回はスーパーで買った三つ葉の根っこを使用
* Rapberry Pi 2 (OS: Raspbian Stretch)
* [Camera Module V2](https://www.raspberrypi.org/products/camera-module-v2/)
* [Google Drive API](https://developers.google.com/drive/v3/web/about-sdk)
* [Google Cloud Vision API](https://cloud.google.com/vision/?hl=ja)


# システム構成
![image.png](https://qiita-image-store.s3.amazonaws.com/0/14390/8750fd54-a07a-3c2a-151a-609be8c674ce.png)

* 植物の定期撮影とGoogle Driveへのアップロードまでを自動化しています
 * 最後の動画作成まで自動化できていないのですが、画像の取捨選択や動画化する範囲の調整をかけることもあるので、それはそれで良いかと
* 水の入れ替え時や、夜の間は何も映らないので、Cloud Vision APIを使って植物が写っているものだけ仕分けしてアップロードするようにしました

# 実際のセッティング
![image.png](https://qiita-image-store.s3.amazonaws.com/0/14390/1488e2c2-da22-d4d7-82ca-c37d15cc42cb.png)

* カメラモジュールは、手元にあったBluetoothドングルのプラスチックパッケージがジャストフィットしたので、それに入れて下部にクリップをたくさん並べることでカメラ台として使っています
* 左側のアルミホイル部分は植物への光量をLEDで補強しようとした名残で、今回は使用していないので気にしないでください
* 常に同じ場所に植物を置くために、新聞紙の線を目安にしていました

と、こう書いてみるとめちゃくちゃ雑セッティングですねw
まぁこんなんでも一応撮影できるということで(汗

# 実装
## カメラによる連続撮影
初期設定や撮影コマンドについては[こちら](https://www.rs-online.com/designspark/raspberry-pi-camera)を参考にさせてもらいました
[raspistill](https://www.raspberrypi.org/documentation/raspbian/applications/camera.md)コマンドはデフォルトでタイムラプスモード(一定間隔毎に撮影)が付いてるのでお手軽です

以下のコマンドを発行し、バックグラウンドで動かしておきます

```
$ nohup raspistill \
-o images/plants-%04d.jpg \
-w 512 \
-h 512 \
-tl 600000 \
-t 86400000 &
```
オプションは

* -o: 出力ファイル名(%04d部分は連番になります)
* -w, -h: 画像の幅と高さ
* -tl: 撮影間隔(ミリ秒)
* -t: 撮影期間(ミリ秒)

となっており、今回は10分間隔で一週間連続で撮影する設定にしています


### カメラ使用中に出たエラー
たまに以下のようなエラーが出て撮影できなくなることがあります

```
$ raspistill -o image.jpg
mmal: mmal_vc_component_enable: failed to enable component: ENOSPC
mmal: camera component couldn't be enabled
mmal: main: Failed to create camera component
mmal: Failed to run camera app. Please check for firmware updates
```

どうも撮影期間が終わると出がちな印象
自分の環境ではRaspberry Piを再起動することで回復しました(残念ながら根本的には未解決)

## Pythonの各種ライブラリの準備
Googleの各種APIを使用するためのクライアントライブラリなどを導入します

```requirement.txt
cachetools==2.0.1
certifi==2017.11.5
chardet==3.0.4
google-api-core==0.1.2
google-api-python-client==1.6.4
google-auth==1.2.1
google-cloud-vision==0.29.0
googleapis-common-protos==1.5.3
grpcio==1.7.3
httplib2==0.10.3
idna==2.6
oauth2client==4.1.2
protobuf==3.5.0.post1
pyasn1==0.4.2
pyasn1-modules==0.2.1
pytz==2017.3
requests==2.18.4
rsa==3.4.2
six==1.11.0
uritemplate==3.0.0
urllib3==1.22
```

インストール

```bash
$ pip install -r requirements.txt
```


## Cloud Vision APIを利用した画像の判定
[リファレンスのサンプルコード](https://cloud.google.com/vision/docs/reference/libraries#client-libraries-install-python)を参考に画像内の物体検出を行います

```python3:vision.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import io
import os

# Imports the Google Cloud client library
from google.cloud import vision
from google.cloud.vision import types

import argparse

def get_labels(file_name):
    client = vision.ImageAnnotatorClient()
    with io.open(file_name, 'rb') as image_file:
        content = image_file.read()
    image = types.Image(content=content)
    response = client.label_detection(image=image)
    return response.label_annotations


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description = "use google cloud vision api")
    parser.add_argument("--image", type=str, help = "Image file path", required=True)
    args = parser.parse_args()
    labels = get_labels(args.image)
    print(labels)
```

動作させる際には、[How the Application Default Credentials work](https://developers.google.com/identity/protocols/application-default-credentials)の手順(1の手順だけでOK)に従って、予めクレデンシャルJSONをダウンロードしておきます

ダウンロードしたら、環境変数 *GOOGLE_APPLICATION_CREDENTIALS* にファイルパスを設定して動かします

このスクリプト単体でも以下のようにAPIの動作確認ができます

```
$ export GOOGLE_APPLICATION_CREDENTIALS=./vision-credential.json
$ ./vision.py --image hoge.jpg
[mid: "/m/02s195"
description: "vase"
score: 0.8704052567481995
, mid: "/m/0fm3zh"
description: "flowerpot"
score: 0.8399174809455872
, mid: "/m/089mxq"
description: "glass bottle"
score: 0.6877408027648926
, mid: "/m/01p7_y"
description: "ikebana"
score: 0.6593118906021118
, mid: "/m/03fp41"
description: "houseplant"
score: 0.5436444878578186
, mid: "/m/050h26"
description: "drinkware"
score: 0.5407968163490295
, mid: "/m/04dr76w"
description: "bottle"
score: 0.5251991152763367
]

```
うまく動作すれば検出された物体のラベル名が返ってくることが確認できるかと思います


## Google Driveへの画像のアップロード
[Raspberry Pi カメラで撮った写真をGoogle Driveへアップロードする](https://iotdiyclub.net/raspberry-pi-camera-picture-upload-google-drive-1/)という記事を参考にさせていただきました(OAuth2の仕組みもわかるとても良い記事です)

### OAuth2による認証部分
ほぼ参考記事のままです
Googleの[Developer Console](https://console.developers.google.com)でOAuthクライアントIDを作成し、シークレットJSONをダウンロードして、client_secret.jsonという名称で保存しておきます

```python:oauth2gdrive.py
from __future__ import print_function
import os
 
from oauth2client import client
from oauth2client import tools
from oauth2client.file import Storage
 
try:
    import argparse
    flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args()
except ImportError:
    flags = None
 
CLIENT_SECRET_FILE = 'client_secret.json'
CREDENTIAL_DIR = '.credentials'
CREDENTIAL_FILE = 'google-drive-credential.json'
 
def get_credentials(application_name, scopes):
    """Gets valid user credentials from storage.
 
    If nothing has been stored, or if the stored credentials are invalid,
    the OAuth2 flow is completed to obtain the new credentials.
 
    Returns:
        Credentials, the obtained credential.
    """
    current_dir = os.path.dirname(os.path.abspath(__file__))
    credential_dir = os.path.join(current_dir, CREDENTIAL_DIR)
    if not os.path.exists(credential_dir):
        os.makedirs(credential_dir)
    credential_path = os.path.join(credential_dir, CREDENTIAL_FILE)
 
    store = Storage(credential_path)
    credentials = store.get()
    if not credentials or credentials.invalid:
        flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, scopes)
        flow.user_agent = application_name
        if flags:
            credentials = tools.run_flow(flow, store, flags)
        else: # Needed only for compatibility with Python 2.6
            credentials = tools.run(flow, store)
        print('Storing credentials to ' + credential_path)
    return credentials
```
これにより初回起動時にOAuth認証が走り、ユーザーが許可するとクレデンシャルファイルがローカルに生成されます
一度認証すれば以後は同じクレデンシャルファイルでAPIが利用できます

### 物体検出をしながら、画像をアップロードしていく(アプリケーション本体)
ここまでのものを組み合わせて動作させます
動作の流れは以下

* OAuth2認証用のクレデンシャル取得
* 指定したディレクトリ配下にある画像ファイルをリストアップ
* 全画像保存用ディレクトリ(TL_ALL)に、画像ファイルを一旦すべてアップロード
* 画像をCloud Vision APIで物体検知
* 正解とする物体の名称が判定結果に含まれていたら、その画像を正解ディレクトリ(TL_CORRECT)にアップロード


```python:plant_watcher.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function
import httplib2
import os
import shutil
import sys
import argparse

from glob import glob
from apiclient import discovery
from apiclient.http import MediaFileUpload
from oauth2gdrive import get_credentials
from vision import get_labels
 
# OAuth 2.0で設定するApplication ID
APPLICATION_NAME = 'my-drive-client'
# OAuth 2.0で設定するSCOPE：Google Driveにおいてファイル単位のアクセスを許可
SCOPES = 'https://www.googleapis.com/auth/drive.file'
 
# アップロードするファイルが格納されているディレクトリ
IMG_DIR = 'images'
# アップロードするファイルの拡張子
FILE_EXTENSION = 'jpg'
# ファイルを格納するGoogle Driveのフォルダ
# 全画像を入れるフォルダ
DRIVE_DIR_ALL = 'TL_ALL'
# 検知したい物体が写っている画像だけを入れるフォルダ
DRIVE_DIR_CORRECT = 'TL_CORRECT'
# Google DriveのフォルダのmimeType
FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'
# アップロードするファイルのmimeType
FILE_MIME_TYPE = 'image/jpeg'

# 検知したい物体のラベル名
CORRECT_LABELS = ['flowerpot','houseplant','plant','bonsai','wood']

def create_folder(drive_service,dir_name):
    ''' Google Driveにアップロードするファイルを格納するフォルダを作成する '''
    # 指定のフォルダが存在するかを問い合わせる
    query = "mimeType='" + FOLDER_MIME_TYPE + "'" + " and name='" + dir_name + "'"
    response = drive_service.files().list(q=query,
                                         spaces='drive',
                                         fields='files(id, name)').execute()
    for folder in response.get('files', []):
        # 指定のフォルダが存在する場合は、そのフォルダIDを返却し復帰する
        print('Folder already exists:' + folder.get('name'))
        return folder.get('id')
 
    # 指定のフォルダを新規に作成し、そのフォルダIDを返却する
    folder_metadata = {
        'mimeType': FOLDER_MIME_TYPE,
        'name': dir_name,
    }
    folder = drive_service.files().create(body=folder_metadata,
                                        fields='id, name').execute()
    print('Folder created:' + folder.get('name'))
    return folder.get('id')
 
def upload_file(drive_service, drive_folder_id, upload_file_path):
    ''' Google Driveの指定したフォルダにファイルをアップロードする　'''
 
    # upload_file_pathがらファイル名を抽出する
    file_name = os.path.split(upload_file_path)[-1]
 
    # アップロードするファイル名が存在するかを問い合わせる
    query = "'" + drive_folder_id + "' in parents and mimeType='" + FILE_MIME_TYPE + "' and name='" + file_name + "'"
    response = drive_service.files().list(q=query,
                                         spaces='drive',
                                         fields='files(id, name)').execute()
    for upload_file in response.get('files', []):
        # ファイル名が存在する場合は、アップロードをスキップし復帰する
        print('File already exists:' + upload_file.get('name'))
        return
 
    # Google Driveの指定したフォルダへファイルをアップロードする
    media = MediaFileUpload(upload_file_path, mimetype=FILE_MIME_TYPE, resumable=True)
    file_metadata = {
        'mimeType': FILE_MIME_TYPE,
        'name': file_name,
        'parents': [drive_folder_id],
    }
    created_file = drive_service.files().create(body=file_metadata,
                                                media_body=media,
                                                fields='id, name').execute()
    print('Upload sucssesful:' + created_file.get('name'))


def upload_files(watch_dir):
    # OAuth 2.0の認証プロセスを実行する
    credentials = get_credentials(APPLICATION_NAME, SCOPES)
    http = credentials.authorize(httplib2.Http())
    drive_service = discovery.build('drive', 'v3', http=http)
    folder_all = create_folder(drive_service,DRIVE_DIR_ALL)
    folder_correct = create_folder(drive_service,DRIVE_DIR_CORRECT)

    trash_dir = watch_dir + '/trash'
    if not os.path.exists(trash_dir):
        os.mkdir(trash_dir)

    upload_files_path = glob(os.path.join(watch_dir, '*.' + FILE_EXTENSION))
    if not upload_files_path:
        # 該当するファイルが存在しない場合は終了する
        print('File does not exist. It does not upload.')
        sys.exit()

    for file_path in upload_files_path:
        print('Upload file:' + file_path)
        # 保存用にとりあえず全部アップロードはする
        upload_file(drive_service, folder_all, file_path)
        labels = get_labels(file_path)
        print(labels)
        for l in labels:
            if l.description in CORRECT_LABELS:
                print('OK :' + file_path)
                # 正解はディレクトリを分けてアップロード
                upload_file(drive_service, folder_correct, file_path)
                break
        # 処理が終わったらゴミ箱ディレクトリに移動しておく
        shutil.move(file_path,trash_dir)
 
if __name__ == '__main__':
    upload_files("./images")
```

OAuth認証の初回のクレデンシャル取得時はブラウザを利用する必要がありますが、Raspberry PiをCLIで動かしているので、一度以下のコマンドで動かします

```
$ python plant_watcher.py --noauth_local_webserver
```
この時表示されるURLをGUIが使える環境のブラウザで開き認証を行います
以後は保存されたクレデンシャルを使ってAPIを叩くことができるようになります

アプリケーションを定期的に動作させたいので、簡単なシェルスクリプトでラップして動かしておきます

```bash:loop_watch.sh
#!/bin/bash

HOME_DIR=/home/pi
# pyenvのvirtual-envを使っていたので、環境をアクティベート
source ${HOME_DIR}/.pyenv/versions/plant-watcher/bin/activate

SCRIPT_DIR=${HOME_DIR}/github/plant-watcher
export GOOGLE_APPLICATION_CREDENTIALS=${SCRIPT_DIR}/vision.json

while :
do
    # 30分毎に稼働を繰り返す
    python ${SCRIPT_DIR}/plant_watcher.py
    sleep 1800
done
```


## 撮影した画像が溜まったら、ダウンロードして動画を作る
### 画像群のダウンロード
Google Driveに画像がたまったらダウンロードします
WebUIからダウンロード可能ですが数が多いと遅かったので、アップロードと同じ要領で[ダウンロードスクリプト](https://github.com/siwasu17/plant-watcher/blob/master/downloadfiles.py)を書いて落としたりしました

### タイムラプス動画作成
iMovieを使用して動画作成します
プロジェクトに画像群を追加した後、 **画像をすべて選択した状態**で 、クロップのスタイルを *フィット* にします
![image.png](https://qiita-image-store.s3.amazonaws.com/0/14390/b41bef2d-5b5f-3cf2-3e46-235f54d6d46e.png)


その後、クリップ情報を選んで画像1枚あたりの継続時間を変更 
![image.png](https://qiita-image-store.s3.amazonaws.com/0/14390/3b613efb-6b85-ed36-4048-7e32612642e9.png)

これで書き出せばタイムラプス動画の完成です！
(仕様なのか継続時間を0.1秒より小さくできなかったので、再生時に倍速して見るのをおすすめします)



# 感想と課題
* 植物の動きが想像以上に面白い
    * 長期で見るとこんなにアクティブに動いていると思わなかった
* カメラの向き先を変えたら防犯カメラ的な使い方もできそう
* APIの認証方式を統一したい
    * OAuth認証時にスコープを半角スペース区切りで渡せば複数スコープ指定も可能なので、OAuthに寄せられるはず([参考元](https://qiita.com/anyworks@github/items/bdba3cd8f17e1d6cc8b3) )
* 撮影環境について
    * 写真がぼやける
        * カメラモジュールは[近距離だとピントが合わないらしい](http://blawat2015.no-ip.com/~mieki256/diary/201607151.html)
    * 位置や向きを変えないように注意が必要(当たり前だけど)
        * うっかりするとすぐキング・クリムゾンしちゃう