RaspberryPi＋Python3＋OpenCVで作る動体検知機能付き防犯カメラ

#はじめに

外出自粛が叫ばれる昨今、おうちハックを楽しみたいと思い、RaspberryPIを購入しました。
当初はRaspofityを導入して音楽を垂れ流すぐらいにしか使っていなかったのですが、
勿体ないので他に使えるものはないかと思い、防犯カメラ化しました。

##動作環境
```
・PC側デバッグ環境
  ・Windows 10 Home 64bit
　・Python 3.7.4(Anaconda3で導入)
　・OpenCV 4.2.0.32

・RaspberryPI Model3B側本番環境
　・Raspbian 10.3
　・Python 3.7.3
　・OpenCV 3.4.6.27
　・お手持ちのWebカメラ等
```
デバッグ環境と本番環境でOpenCVのバージョンが違いますが、Raspbian側でOpenCV4が動作しないためあって、ほかに大きな意味はありません。(デバッグ環境側で揃えればいいんですが、製作最後半で気づいたミスで環境構築し直すのも大変なのでプログラム側でどうにかしました)
ディレクトリ構成は以下の通りです。

```
root/
├input(デバッグ用の入力映像フォルダ)
├output(動体検出した映像の一時保存用フォルダ)
├main.py(動体検出部分)
├util.py(映像保存用モジュール)
└mailing.py(メール送信用モジュール)
```

以下、RaspberryPIは環境構築完了・OpenCVも導入済みとして話を進めます。

#動体検出をする
動いているものがあるときのみ録画するようにしたいので、動体検出ができるようなソースコードにします。
(人間が対象なので、OpenCVに内蔵されているカスケード分類器を用いた物体検出器を使う、機械学習にかけるという方法もありますが、動作の軽さと検出精度のバランスから動体検出で済ませています)

```main.py
#configファイルの読み込み
import configparser
# ファイルの存在チェック用モジュール
import os
import errno
#モジュールのバージョンチェック
from distutils.version import StrictVersion
#opencv
import cv2
#zipファイル圧縮用
import zipfile
#フォルダ削除用
import shutil

#セルフモジュール
from util import CamSetting
from util import CamRecording
from mailing import SendMail

#=======#configファイルの読み込み#=======#
config = configparser.ConfigParser()
config_ini_path = "config.ini"
# 指定したiniファイルが存在しない場合、エラー発生
if not os.path.exists(config_ini_path):
    raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), config_ini_path)
config.read(config_ini_path, encoding="utf-8")

filepath = config['SETTING']['InputFile']
if not(filepath == '' or 'None'):
    if not os.path.exists(filepath):
        raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), filepath)
output_folder = config['SETTING']['OutputFolder']
if not os.path.exists(output_folder):
    os.mkdir(output_folder)
kernel_size = config['SETTING']['MedianFilterKernelSize']

#=======#configファイルの読み込み 終わり#=======#

#カメラもしくは映像の読み込み
cap = CamSetting(filepath)

#比較用フレーム
avg = None

#ビデオ保存用インスタンスの生成
rec = CamRecording(cap, output_folder)

#録画可能状態の監視フラグ
flag = -1

while True:
    # 1フレームずつ取得する。
    ret, frame = cap.read()
    if not ret:
        break
```
普通のやり方で動画読み込みをしていきます。後ほど変える可能性がある設定はconfigファイルから読み込んでいます。

```main.py
    # グレースケールに変換
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    # 比較用のフレームを取得する
    if avg is None:
        avg = gray.copy().astype("float")
        continue

    # 現在のフレームと移動平均との差を計算
    cv2.accumulateWeighted(gray, avg, 0.6)
    frameDelta = cv2.absdiff(gray, cv2.convertScaleAbs(avg))

    # 差分画像を閾値処理を行う
    thresh = cv2.threshold(frameDelta, 3, 255, cv2.THRESH_BINARY)[1]

    #ノイズ除去
    ksize=int(kernel_size)
    #メディアンフィルタ
    thresh = cv2.medianBlur(thresh, ksize)

    # 画像の閾値に輪郭線を入れる
    if StrictVersion(cv2.__version__) >= StrictVersion('4.0'):
        contours, hierarchy = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    elif StrictVersion(cv2.__version__) < StrictVersion('4.0'):
        _ ,contours, hierarchy = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    frame = cv2.drawContours(frame, contours, -1, (0, 255, 0), 3)
```

ここから動体検出していきます。大部分はOpenCV3とOpenCV4で共通ですが、cv2.findContoursのみ、アンパッキングされる変数の数が異なるのでif文で分岐させています。

```main.py
    if contours:
        #録画停止モードから録画可能モードに遷移する
        if flag == -1:
            #ビデオ保存用インスタンスの生成
            rec = CamRecording(cap, output_folder)
        #検知する動体がある場合にビデオを保存する
        flag = rec.Recording(frame, True)
    else:
        flag = rec.Recording(frame, False)

    # 結果を出力
    cv2.imshow("Frame", frame)
    key = cv2.waitKey(10)
    if key == 27:
        break

del rec
cap.release()
cv2.destroyAllWindows()
```

contours(輪郭線)がある場合、すなわち検出する動体がある場合にのみ録画されるようCamRecordingクラスに変数を渡しています。(詳しくは後述）
Whileループを抜けたあとにrecインスタンスを削除しないと(＝util.pyに記述されたrelease()を呼び出す)、最後にフォルダを削除するときにエラーを吐かれるので記述しておきます。

```main.py
#生成された動画ファイルを圧縮する
zip_filename = './output/new_comp.zip'
with zipfile.ZipFile(zip_filename, 'w', compression=zipfile.ZIP_STORED) as new_zip:
    for filepath in CamRecording.video_file:
    #for filepath in CamRecording.video_file:
        basename = os.path.basename(filepath)
        new_zip.write(filepath, arcname=basename)

#GmailでZipファイルを送信
SendMail(zip_filename)

#動画ファイルとZipファイルをディレクトリごと削除する
shutil.rmtree('./output/')
```

録画された映像をZipファイルに圧縮してGmailで送信します。
今回、事情から一旦outputフォルダに録画映像を書き出してメール送信したのち、outputフォルダごと削除することにしています。
続いて、util.pyについて説明します。

#動画を録画する
```util.py
#録画の設定をする
import cv2
import uuid

def CamSetting(video_name=None):
    if filepath is None:
        cap = cv2.VideoCapture(0)
    else:
        cap = cv2.VideoCapture(video_name)
    
    #画像サイズを変更する(MAX:720×480)
    width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
    height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)

    if width > 720 or height > 480:
        width = 720
        height = width/height * 480
        cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) # カメラ画像の横幅をwidthに設定
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) # カメラ画像の縦幅をheightに設定

    return cap
```

カメラもしくはデバッグ用の映像を読み込みます。処理の負荷を軽減させるために最大サイズが720x480になるようリサイズします。

```util.py
class CamRecording():
    video_file = []
    def __init__(self, camera, output_folder):
        # 動画ファイル保存用の設定
        fps = int(camera.get(cv2.CAP_PROP_FPS))                    # カメラのFPSを取得
        w = int(camera.get(cv2.CAP_PROP_FRAME_WIDTH))              # カメラの横幅を取得
        h = int(camera.get(cv2.CAP_PROP_FRAME_HEIGHT))             # カメラの縦幅を取得
        fourcc = cv2.VideoWriter_fourcc('W', 'M', 'V', '1')
        #fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v')        # 動画保存時のfourcc設定（mp4用）
        
        #生成されたビデオの一覧を追加しておく
        file_name = str(uuid.uuid4())[:6]
        self.video_file = output_folder + file_name + '.wmv'
        self.__class__.video_file.append(self.video_file)
        
        self.video = cv2.VideoWriter(self.video_file,
                                       fourcc, 
                                       fps,
                                       (w, h))  # 動画の仕様（ファイル名、fourcc, FPS, サイズ
   
        self._counter = 0
```

uuid4で名前が被らないように動画ファイルの名前を設定、後ほどmain.pyでZipファイルに圧縮するときのために、リストのvideo_fileに一時保存した動画ファイルのパスを渡しておきます。

本当なら、一時的に動画ファイルへ書き出すことはせず、メモリストリームで仮想的に動画ファイルをやりとりしたかったのですが、どうやらopencvからメモリストリームを利用することはできないようです。(以下参照)
[Streaming video in memory with OpenCV VideoWriter and Python BytesIO](https://stackoverflow.com/questions/51052268/streaming-video-in-memory-with-opencv-videowriter-and-python-bytesio)

```util.py
    def Recording(self, frame, recordable=False):
        if recordable is True:
            self.video.write(frame)
            self._counter = 0
            return 0
        else:
            self._counter += 1
            #動体検出がなにも検出しない状態が
            #30フレーム続いたら録画を終了する
            if self._counter == 30:
                self.video.release()
                return -1
            elif self._counter > 30:
                return -1 #録画終了状態を継続
            else:
                self.video.write(frame)
                return 0
            return 0


    def __del__(self):
        self.video.release()
```

動体検出がなにも検出しない状態が30フレーム(＝1秒)続いたら、動画を保存するように設定しています。

最後に検出された動画をメールで送信する部分について説明します。

#メールで送信する
```mailing.py
import os
from smtplib import SMTP
from email.mime.text import MIMEText
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
 
import configparser

#=======#configファイルの読み込み#=======#
config = configparser.ConfigParser()
config_ini_path = "mailing.ini"
config.read(config_ini_path, encoding="utf-8")

# SMTP認証情報
account = config['MAILSETTING']['account']
password = config['MAILSETTING']['password']
 
# 送受信先
to_email = config['MAILSETTING']['to_email']
from_email = config['MAILSETTING']['from_email']
#=======#configファイルの読み込み 終わり#=======#

def SendMail(zipfile):
    # MIMEの作成
    msg = MIMEMultipart()

    subject = "This is the recorded security camera footage"
    message = "recorded security camera"

    msg["Subject"] = subject
    msg["To"] = to_email
    msg["From"] = from_email
    body = MIMEText(message, "html")
    msg.attach(body)

    # 添付ファイルの設定
    basename = os.path.basename(zipfile)
    attach_file = {'name': basename, 'path': zipfile}
    attachment = MIMEBase('aplication', 'zip')
    file = open(attach_file['path'], 'rb+')
    attachment.set_payload(file.read())
    file.close()
    encoders.encode_base64(attachment)
    attachment.add_header("Content-Disposition", "attachment", filename=attach_file['name'])
    msg.attach(attachment)

    # メール送信処理
    try:
        server = SMTP("smtp.gmail.com", 587)
        server.starttls()
        server.login(account, password)
        server.send_message(msg)
        server.quit()
        print("Successfully sent email")
    except Exception:
        print("Error: unable to send email")
```

メールアドレスやログインパスワードなどの機密情報は別途configファイルに記録して読みだしています。

#デモ
[モーションリファレンス【歩く】 | #03 成人女性が元気に歩く | FUN'S PROJECT COLLEGE](https://www.youtube.com/watch?v=IeZvuOMbXNc&feature=youtu.be)
をデモにして実際に動くかどうかテストしてみます。以下、RaspberryPIで実行してみた結果です。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">動体検出のデモ <a href="https://t.co/J1kmmFPeUO">pic.twitter.com/J1kmmFPeUO</a></p>&mdash; 木魚 (@maccha_tech) <a href="https://twitter.com/maccha_tech/status/1253939789276917760?ref_src=twsrc%5Etfw">April 25, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

動体検出しているところのみの抽出に成功しました！

#参考文献
[OpenCVで手っ取り早く動体検知してみた](https://qiita.com/K_M95/items/4eed79a7da6b3dafa96d)
[Pythonでメールを送信](https://qiita.com/kawa-Kotaro/items/460977f050bf0e2828f2)