Raspberry Pi で 防犯カメラ を作る

## やりたいこと
Raspberry Pi（以下「ラズパイ」）を使用し、動体検知で録画を開始、録画データをGoogleDriveに転送する防犯カメラを作りたい！
## 準備
- Raspberry Pi 4 model b
- ラズパイケース
- Camera V2.1
- microSD(32G)
- HDMIケーブル
- 電源ケーブル
- ヒートシンク
- 三脚
- ラズパイ操作用ローカルPC（MacBook Air 2020,Intel)
## やったこと
1. ラズパイにOSを導入
1. ラズパイのネットワーク設定
1. ローカルPCとラズパイ間でSSH接続
1. プログラム作成（動体検知→録画→データ転送）
1. 自動起動設定（ラズパイ電源ONでプログラム起動）
### 1. ラズパイにOSを導入
まず、ローカルPCを用いてmicroSDカードにOSをインストールします。
OSは、[Raspberry Pi公式](https://www.raspberrypi.com/software/)からダウンロードしたMac用のImagerを使い、`Raspberry Pi OS(Legacy, 64-bit)Lite`のバージョン`Bullseye`をインストールしました。
（できるだけmicroSDの容量を圧迫しないように、デスクトップ環境なしのOSにしました）

:::note warn
当初、Raspberry Pi OS Lite(64-bit)のバージョンBookwormをインストールしたんですが、カメラを認識しなかったため、バージョンをBullseyeに下げた経緯があります
:::
### 2. ラズパイのネットワーク設定
[こちら](https://yunkt.hatenablog.com/entry/2019/12/08/150921)の記事を参考に各種設定（アカウント、キーボード、Wi-Fi、SSH）を行いました。
しかし、ネットワークになかなか繋がりませんでした。Wi-Fiの設定ファイル`/etc/wpa_supplicant/wpa_supplicant.conf`を確認したところ、Wi-Fiは問題なく設定できていました。色々調べてみると、[こちら](https://ochanjanai.net/it/146)の記事に遭遇し、`/etc/network/interfaces`に無線LANの設定を書き込むと、ネットワークに繋がりました。
```:/etc/network/interfaces
auto wlan0
iface wlan0 inet dhcp
wpa-conf /etc/wpa_supplicant/wpa_supplicant.conf
```
### 3. ローカルPCとラズパイ間でSSH接続
[こちら](https://qiita.com/Yui-00/items/8bc9be260db8ec668f65)の記事を参考に接続することができました。これでいよいよプログラムの作成に入ります。
```
$ ssh ユーザー名@ホスト名.local
```
### 4. 動体検知プログラム作成
環境はこちらです。
```
$ python --version
Python 3.9.2
```
```
$ pip -V
pip 20.3.4 from /usr/lib/python3/dist-packages/pip (python 3.9)
```
プログラムは、主に[こちら](https://qiita.com/KMiura95/items/4eed79a7da6b3dafa96d)の記事を参考に作成しました。
※ラズパイのカメラを三脚に設置すると画面が90度傾くため、作成したプログラム内で画面を90度回転させています。
<details><summary>security_camera.py</summary>

```python:security_camera.py
import cv2
import os
from GoogleDriveUpload import googleDriveUpload
from datetime import datetime

def main():
    ## Config
    cap = cv2.VideoCapture(0)        # Videoデバイスの番号指定
    max_file_mb = 10                 # 動画ファイルサイズの上限（MB）
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)

    ## 変数定義
    avg = None              # 移動平均を保持する変数
    ignore_pixel = 100      # 動体検知する際に無視するpixel（小さいほど敏感）
    frameCount = 0          # フレームカウント
    afterFrameCount = 90    # 動体検知しなくなってから録画停止するまでのフレーム数
    status = None           # ステータス（Recording or Stop)
    out = None              # 出力ファイルを保持する変数
    output_file = None      # 生成ファイル

    ## 動画プロパティ
    width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)    # フレームの幅
    height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)  # フレームの高さ
    frameSize = (int(width),int(height))         # フレームサイズ
    fps = int(cap.get(cv2.CAP_PROP_FPS))         # カメラのfpsを取得

    while True:

        # 1フレームずつ取得
        ret, frame = cap.read()
        if not ret:
            break

        # 動画を90度回転
        frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
        frameSize = (int(height), int(width)) # frameの縦・横を入れ替える

        # 取得したフレームを白黒に変換
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

        # 比較用のフレームを取得
        if avg is None:
            avg = gray.copy().astype("float")
            continue

        #　現在のフレームと移動平均との差を計算し、avgを更新
        cv2.accumulateWeighted(gray, avg, 0.5) #　第3引数の値が大きいほどgrayの影響が大きくなる
        #　現在のフレームとavgとの差異部分を検出
        frameDiff = cv2.absdiff(gray, cv2.convertScaleAbs(avg)) 

        # 差異部分の閾値処理を行う（framDiffの各ピクセルが20より大きい→255、20以下→0に変換）
        thresh = cv2.threshold(frameDiff, 20, 255, cv2.THRESH_BINARY)[1]

        # 差異部分の白部分の画素数を取得
        white_area = cv2.countNonZero(thresh)

        # 動画埋め込み用の現在の日付と時刻を取得
        current_time = datetime.now().strftime("%Y.%m.%d %H:%M:%S")
        # フレームに日付と時刻を表示
        cv2.putText(frame, current_time, (20, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1)

        # 録画（動体検知した場合、または、動体検知後一定時間経過するまで）
        # 白画素数が一定以上あれば動体検知
        if (white_area > ignore_pixel) or ((status == 'Recording') and (frameCount < afterFrameCount)):
            if status != 'Recording':
                status = 'Recording'
                print(status)
            # ファイルサイズチェック
            if output_file != None and (os.path.getsize(output_file) / 1024 / 1024 > max_file_mb):
                out.release()
                # GoogleDriveに転送
                googleDriveUpload(output_file)
                out = None
            # ファイル作成判定
            if out is None:
                date = datetime.now().strftime("%Y%m%d_%H%M%S")
                dir_path = "./" + datetime.now().strftime("%Y%m%d")
                # フォルダ作成判定（日付フォルダ作成し、ファイル格納）
                if not os.path.isdir(dir_path):
                    os.makedirs(dir_path)
                output_file = dir_path + "/" + date + ".mp4"
                fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v')
                out = cv2.VideoWriter(output_file, fourcc, fps, frameSize)
            out.write(frame)

        # 動体検知した場合はフレームカウントをリセット
        if white_area > ignore_pixel:
            frameCount = 0
        else:
            frameCount += 1

        # 一定時間動体検知がなかったら録画停止
        if frameCount >= afterFrameCount:
            if status == 'Recording':
                status = 'Stop'
                print(status)
                out.release()
                # GoogleDriveに転送
                googleDriveUpload(output_file)
                out = None
            # フレームカウントをリセット
            frameCount = 0

    cap.release()
    out.release()
    cv2.destroyAllWindows()
    return

if __name__ == "__main__":
    main()
```
</details>

GoogleDriveへのアップロードを行うプログラムは別ファイルにしています。
<details><summary>GoogleDriveUpload.py</summary>

```python:GoogleDriveUpload.py
import os.path

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from googleapiclient.http import MediaFileUpload

# 認証jsonのパス
credentials_path = '/home/user/python/credentials.json'
# アクセストークンの保存先のパス
token_path = '/home/user/python/token.json'

def googleDriveUpload(upload_file_path):
    # If modifying these scopes, delete the file token.json.
    SCOPES = ["https://www.googleapis.com/auth/drive"]
    
    # Shows basic usage of the Drive v3 API.
    # Prints the names and ids of the first 10 files the user has access to.
    
    creds = None
    # The file token.json stores the user's access and refresh tokens, and is
    # created automatically when the authorization flow completes for the first
    # time.
    if os.path.exists(token_path):
        creds = Credentials.from_authorized_user_file(token_path, SCOPES)
    # If there are no (valid) credentials available, let the user log in.
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                credentials_path,
                SCOPES,
                redirect_uri="http://localhost/" # redirect_uriを指定
            )
            auth_uri = flow.authorization_url()
            print(
                f"Please visit this URL to authorize this application: {auth_uri[0]}"
            )
            code = input("Enter the authorization code: ")
            flow.fetch_token(code=code)
            creds = flow.credentials
            # creds = flow.run_local_server(port=0)
        # Save the credentials for the next run
        with open(token_path, "w") as token:
            token.write(creds.to_json())

    try:
        service = build("drive", "v3", credentials=creds)

        upload_file_name = os.path.basename(upload_file_path)
        mine_type = 'video/mp4'
        folder_id = 'GoogleDriveの格納先フォルダID'

        media = MediaFileUpload(upload_file_path, mimetype=mine_type, resumable=True)

        file_metadata = {
            'name': upload_file_name,
            'mimeType': mine_type,
            'parents': [folder_id]
        }
        file = service.files().create(
                body=file_metadata, 
                media_body=media 
                    ).execute()

        print(file['id'])

        # Call the Drive v3 API
        results = (
            service.files()
            .list(pageSize=10, fields="nextPageToken, files(id, name)")
            .execute()
        )
        items = results.get("files", [])

        if not items:
            print("No files found.")
            return
        print("Files:")
        for item in items:
            print(f"{item['name']} ({item['id']})")
    except HttpError as error:
        # TODO(developer) - Handle errors from drive API.
        print(f"An error occurred: {error}")


if __name__ == "__main__":
    googleDriveUpload()
```
</details>

#### 実装した機能
- 動体検知で録画開始
- 動体検知しなくなって一定時間経ったら録画停止
- 録画開始→停止→ファイル作成
- 設定した上限容量に達したらファイル作成
- 動画にタイムスタンプを埋め込む
- 作成したファイルをGoogleDriveへ転送

#### 苦労した点
- GoogleDriveAPIを使うためには、アプリ（ラズパイで作成したプログラム）をGoogleで認証する必要があるのですが、その認証がうまくいきませんでした。
基本的には[こちら](https://news.mynavi.jp/techplus/article/zeropython-16/)の記事と[Pythonのクイックスタートガイド(Google公式)](https://developers.google.com/drive/api/quickstart/python?hl=ja)をベースに進められたのですが、Googleの認証画面にアクセスしようとすると、`エラー 400: redirect_uri_mismatch`が出ました。
     
     原因は、ブラウザに貼り付けていたURLのこの部分でした。`redirect_uri=urn:ietf:wg:oauth:2.0:oob`
     調べてみると、2022年にOAuthクライアントに対するOOBフローのサポートが終了となっていました。[参考](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration?hl=ja)

     解決策を調べたところ、[こちら](https://picscels.site/python-google-api-oauth-retry/)の記事に辿り着き、`redirect_uri=http://localhost/`を手動で指定し、GoogleCloudプロジェクト側でも指定したuriを承認済みリダイレクトURIに追加することで、認証に成功することができました。

     （参考）
    GooglrDriveへのアクセス方法を調べてみると、主に次の２つの方法がありました。
    1. OSでマウントしてアクセスする方法
    2. APIを使ってプログラムからアクセスする方法

    今回は、動画ファイルの転送先を柔軟に変更可能にするため、「2.APIを使ってプログラムからアクセスする方法」でプログラムを作成しました。
### 5. 自動起動設定
プログラムを起動するために、毎回リモートでラズパイに繋いで手動で起動させるのは面倒でしたので、ラズパイの電源をONにすれば、プログラムが自動で起動するように設定します。
やったことは、[オートログインの設定](https://manumaruscript.com/raspi-auto-login/)と[systemdの設定](https://www.pc-koubou.jp/magazine/52061)の２つです。
下記のserviceファイルを作成し、自動起動を有効にするコマンドを入力しました。
<details><summary>raspberrypi.service</summary>

```service:raspberrypi.service
[Unit]
Description=security_camera_startup
ConditionPathExists=/home/user/python

[Service]
ExecStart=/usr/bin/python /home/user/python/security_camera.py

[Install]
WantedBy=multi-user.target
```
</details>

```:自動起動有効設定
$ sudo systemctl enable raspberrypi.service
```


## 振り返り
　未経験入社から約半年の未熟者ですが、職場の上司、先輩、ネット記事に助けてもらいながら、なんとか形にすることができました。とても貴重な経験になりましたし、モノづくりの楽しさ、達成感を味わえたことが、今後のエンジニア人生の糧になると思います。今後もいろんなことに挑戦していきたいです。