【Laravel + JavaScript】ブラウザで監視カメラをつくる(画像保存機能付き)

さてさて、この間QRコードで自動ログインする機能をつくるというような「PCとリアルの融合」をテーマにした記事をいくつか公開しました。

これらの記事の中ではウェブカメラを使ったものが多かったのですが、実はそれにともなっての衝動買いで「ちょっとだけ解像度がいいUSBウェブカメラ」も購入していました。

というのも、今更ながらですがRaspberry Piとそのカメラを使ってモーション・ディテクション(つまり、監視カメラ)を作ってみたかったからです。(将来的には農業用に何か役立たないかな、なんて勝手に考えてました)

ただ、公開済みの記事のことを考えると、あるアイデアが1つ頭に浮かんできたんです。

「これ、ブラウザだけでいけるんじゃない!?」

と。

そうです。

思っている以上にブラウザだけでも色々なことができてしまったんで、一度やってみようかという気分になったんですね。しかも、ブラウザだけで実行できるというは「OSはどれでもOK!」というなり、より汎用的に使えるのはとても魅力的です。(ただ、そのせいでRaspberry Piセットはしばらく寝かせることになりそうですが・・・😅)

ということで、今回はブラウザからウェブカメラを起動して動きを検出。さらに動きがあったらその画像をAjax送信して保存するまでをやってみたいと思います。

ぜひ皆さんのお役に立てると嬉しいです😊✨

開発環境: Laravel 5.8、Vue 2.6、Google Chrome 76

事前の注意

Google Chromeではローカルであってもウェブカメラに接続するには、HTTPS接続である必要があります。もしローカルにHTTPSを導入する場合は以下のページを参考にしてみてください。

コピペでOK!ローカル環境にHTTPSを導入する(nginx編)

Laravel側の作業

ルートをつくる

では、はじめにブラウザからアクセスするURL(ルート)をつくっていきましょう。

routes/web.phpを開いて以下2つを追加してください。

Route::get('motion_detection/show', 'MotionDetectionController@show');
Route::post('motion_detection/save_image', 'MotionDetectionController@save_image');

コントローラーをつくる

続いてコントローラーです。
以下のコマンドを実行してください。

php artisan make:controller MotionDetectionController

すると、app/Http/Controllers/MotionDetectionController.phpが作成されるので中身を以下のように変更してください。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class MotionDetectionController extends Controller
{
    public function show() {

        return view('motion_detection.show');

    }

    public function save_image(Request $request) {

        $result = false;

        if($request->hasFile('image')) {

            $request->file('image')->store('motion_detection'); // storage/app/motion_detectionに保存
            $result = true;

        }

        return ['result' => $result];

    }
}

save_image()でやっていることは、画像データが送信されてきたら、storage/app/motion_detectionに保存するというシンプルなものになっています。

ビューをつくる

最後にブラウザで表示するビューresources/views/motion_detection/show.blade.phpを作成し、中身を以下のようにします。

<html>
<body>
    <div id="app">
        <h1>JavaScriptで動きを検知して画像保存</h1>
        <video ref="video" width="640" height="480"></video>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.0/axios.min.js"></script>
    <script>

        new Vue({
            el: '#app',
            data: {
                canvas: null,
                imageData: null,
                detecting: false,
                differenceThreshold: 10 // どれだけ違いがあれば動いたと判断するか(RGB)
            },
            computed: {
                video() {

                    return this.$refs['video'];

                },
                context() {

                    return this.canvas.getContext('2d');

                }
            },
            methods: {
                detectMotion() {

                    setInterval(() => {

                        if(!this.detecting) {

                            this.detecting = true;
                            const prevImageData = this.imageData;
                            const video = this.video;
                            this.context.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
                            this.imageData = this.context.getImageData(0, 0, video.videoWidth, video.videoHeight);

                            if(this.hasDifference(prevImageData, this.imageData)) { // 2枚の画像に違いがあるかをチェック

                                // 画像を送信
                                this.canvas.toBlob((blob) => {

                                    const url = '/motion_detection/save_image';
                                    let formData = new FormData();
                                    formData.append('image', blob);

                                    axios.post(url, formData)
                                        .then((response) => {

                                            if(response.data.result) {

                                                console.log('画像を保存しました');

                                            }

                                        })
                                        .catch((error) => {

                                            // エラー処理

                                        })
                                        .then(() => {

                                            this.detecting = false;

                                        });

                                });

                            } else {

                                this.detecting = false;

                            }

                        }

                    }, 500);

                },
                hasDifference(prevImageData, currentImageData) {

                    if(prevImageData === null) {

                        return false;

                    }

                    for(let i = 0; i < currentImageData.data.length; i += 4) {  // 画像をピクセル単位で比較

                        let prevRGB = {
                            red: prevImageData.data[i] / 3,
                            green: prevImageData.data[i + 1] / 3,
                            blue: prevImageData.data[i + 2] / 3,
                        };
                        let currentRGB = {
                            red: currentImageData.data[i] / 3,
                            green: currentImageData.data[i + 1] / 3,
                            blue: currentImageData.data[i + 2] / 3,
                        };

                        let differences = {
                            red: Math.abs(prevRGB.red - currentRGB.red),
                            green: Math.abs(prevRGB.green - currentRGB.green),
                            blue: Math.abs(prevRGB.blue - currentRGB.blue),
                        };

                        for(let key in differences) {

                            if(differences[key] > this.differenceThreshold) {

                                return true;

                            }

                        }

                        return false;

                    }

                }
            },
            mounted() {

                // ウェブカメラへアクセス
                navigator.mediaDevices.getUserMedia({ video: true })
                    .then((stream) => {

                        this.video.srcObject = stream;
                        this.video.play();

                        this.canvas = document.createElement('canvas');
                        this.canvas.width = this.video.width;
                        this.canvas.height = this.video.height;
                        this.detectMotion();

                    });

            }
        });

    </script>
</body>
</html>

この中で一番重要なのは、hasDifference()です。

このメソッドは「今回取得した画像と前回の画像に違いがあるか??」をチェックしているのですが、これは同じ位置にある色(RGB)の違いをチェックすることで実現しています。

そして、その基準となる値(しきち値)がdifferenceThresholdに格納されているので、ウェブカメラの精度によってこの値を変更するといいでしょう。

テストしてみる

では実際にコードを実行して、テストしてみましょう。
今回テストに使うのは、最近特にハマっているクラフトビール「インドの青鬼」(の、すでにイカと一緒に飲んでしまって空になった缶😊)です。

この缶を手でウェブカメラにフレームインしたり、逆にフレームアウトさせて保存された画像をチェックすることにします。

まず、フレームインしたとき。

次にフレームアウトしたとき。

そして、フレームイン後に缶を離した場合。

最後に再度近づけた場合。

ということで、100%完璧ではない場合もありましたがだいたい何か動きがあれば画像を保存してくれていました。

ちなみに、少しだけ退室することがあったのでそのままにしてみたら何も変化がないはずですが数点画像が保存されていました。

ただ、これは誤作動というよりウェブカメラが自動的に明るさを調整した結果のようです。

そのため、このあたりは計算式をもっと複雑にして精度を上げるか、もしくは機械学習などを使って検出をすべきですが、なにはともあれブラウザを使ってのモーション・ディテクションに成功しましたので今回はヨシとします!

お疲れ様でした😊✨

おわりに

ということで今回はブラウザを使って監視カメラを実装してみました。

ちなみに、途中コードを実行しているのを忘れてしまって、後から保存された自分の姿をみると、まれに「奇跡の1枚」が見つかったと思いきやほぼ全編で「いやいや、もうちょっとカメラを意識した顔しろよ!」と言いたくなる画像ばかりで、自分のことながら少し気恥ずかしい気分になりました(笑)

でも、自分のことを客観的に見るいい機会になったかもしれません。

今度からは、常にPCのウェブカメラを意識してプログラムをつくる・・・・・のはちょっと無理なので、カメラにはシールを貼ってブロックしておきます😂

ぜひ皆さんも一度モーション・ディテクションを試してみてはいかがでしょうか。

ではでは〜!


「いつか、インドの青鬼つくってるヤッホーブルーイングさんが、このブログのスポンサーとかなってくんないかな・・・」