JavaScriptで動体検知PWAを作ってみた【OpenCV.js】

ぶぉんなたーれ!クリスマスはリベといっしょにパネトーネを食べたい人生だった…。

クリスマスといえばサンタさん。
その姿を一目見てやろうと夜更かしすると,サンタさんは来なくなります。
夜更かしは悪い子のすることなので。

悪い子にならずに,サンタさんの姿を確認したい…

ということで,カメラに映る動くものを検知するサイトを作りました。

この記事は SLP KBIT Advent Calendar 2020 25日目の記事です。

adventar.org

こんな感じ。

f:id:yassi-htn:20201224185410p:plainf:id:yassi-htn:20201224185407p:plain
動作例

ここで動作します。
ただし,いまのところ(2020.12.25現在),DesktopのfirefoxではA2HS(ホーム画面に追加)が利用できません。利用可否の確認
代わりに,chrome系のブラウザやEdgeを使うと利用できます。モバイル版ならfirefoxでも利用できます。

https://yassi-github.github.io/move-capture-pwa/

コードはこちら。

https://github.com/yassi-github/move-capture-pwa

PWAとは

developer.mozilla.org

PWAとは,プログレッシブウェブアプリの略です。
ざっくりと表現すると,DLしてインストールするよくあるアプリのようなWebアプリケーションのことです。

  • URLを持つため簡単にアクセスできかつ,オフラインでも利用できる。
  • 端末にDL,インストールしてアプリアイコンから起動することができる。
  • さらに,カメラや位置情報など端末の周辺機器を利用した動作も可能。

他にも,通知やレスポンシブであることなどPWAに関する機能はありますが,今回実装したのは以上のようなこと。

PWAは,その名前にあることから,プログレッシブであることが重要です。
つまり,先ほど挙げた機能を使うためにはブラウザーの機能を利用することが必要ですが,ブラウザーが古くて機能が制限された状態でも,一応は使えるようであることが重要ということです。

…今回の場合はカメラが使えないと何もできないのでプログレッシブではないですね。あ,じゃあPWAではないのでは…?

OpenCV.jsについて

docs.opencv.org

カメラの動きを検知するには,フレームごとの画像処理が必要です。
画像処理といえば,OpenCVですね。
今回はWebアプリケーションなので,JavaScript版となるOpenCV.jsを使います。

これを使うには,releasesからopencv-<virsion>-docs.zipをDLし,その中にあるopencv.jsをhtmlでスクリプトとして読み込みます。
解凍したディレクトリにはたくさんのディレクトリがありますが,opencv.jsは直下にあるので無駄に探し回らないように注意です(一敗)

詳しい手順は,公式サイト(英語)の使い方チュートリアルをご覧ください。
サンプルコードも豊富にありますし,英語を読まなくても雰囲気でわかるかと思います。

実装部分

参考サイトは大体以下の通りです。

プログレッシブウェブアプリ | MDN

OpenCV: Image Thresholding

OpenCV: Getting Started with Videos

鳥取大学さんのサイトには,OpenCVのページを日本語に翻訳したものがあります。Python版ですが。

画像のしきい値処理 — OpenCV-Python Tutorials 1 documentation

PWAの機能

サービスワーカー

サービスワーカーを設定することで,キャッシュにhtmlや何やらを保存し,オフラインでの利用が可能になります。
リソースの取得リクエストを,サーバーの代わりにサービスワーカーが受け取ることでオフライン利用や高速な再読み込みが実現します。

この機能を使うには,サービスワーカーの動作を記述したjsファイルを作成し,それを登録させる必要があります。それにより,初回起動時のインストールの設定,リクエストの受け取りと応答設定,データ更新時のキャッシュ削除設定などを記述できます。

sw.js

let cacheName = 'v1';

self.addEventListener('install', function(event) {
    event.waitUntil(
        caches.open(cacheName).then(function(cache) {
            return cache.addAll([
                '/move-capture-pwa/',
                '/move-capture-pwa/index.html',
                '/move-capture-pwa/main.js',
                '/move-capture-pwa/opencv.js'
            ]);
        })
    );
});

このようにサービスワーカーを設定したら,main.jsのほうで

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('sw.js')
  .then(function(register) {
    if (register.installing) {
      console.log("sw installing");
    } else if (register.waiting) {
      console.log("sw installed");
    } else if (register.active) {
      console.log("sw active");
    }
  }).catch(function(error) {
    console.log("Registration failed with " + error);
  });
}

と書くことで,キャッシュストレージが利用できるようになります。

リクエストをサービスワーカーが応答するなど,他の設定はすべてsw.js(サービスワーカーのjsファイル)のほうに記述していきます。

ホーム画面に追加(A2HS)

モバイルブラウザでQiitaを見ると画面下に出てくるあれです。
これで,ネイティブアプリのように直接起動でき,見た目もフルスクリーンにしたりできます。

この機能を実現するためには,JSON形式のマニフェストファイルを作成する必要があります。
なお,ここで必須となるアイコン画像ですが,サイズが144x144以上でないといけないので注意です。

マニフェストファイルにはこのように,アプリとしての動作設定を記述します。

manifest.webmanifest

{
    "backgroung_color": "LightGreen",
    "display": "fullscreen",
    "icons": [
        {
            "src": "icon/icon256.png",
            "sizes": "256x256",
            "type": "image/png"
        }
    ],
    "name": "Move capture",
    "short_name": "Detect",
    "start_url": "/move-capture-pwa/index.html"
}

その後,main.jsでこのように記述すると,ホーム画面に追加機能が使えるようになります。
(この部分はMDNさんの解説コードをそのまま使っています…)

main.js

let deferredPrompt;
const addBtn = document.querySelector('.a2hs-button');
addBtn.style.display = 'none';

window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent Chrome 67 and earlier from automatically showing the prompt
  e.preventDefault();
  // Stash the event so it can be triggered later.
  deferredPrompt = e;
  // Update UI to notify the user they can add to home screen
  addBtn.style.display = 'block';

  addBtn.addEventListener('click', (e) => {
    // hide our user interface that shows our A2HS button
    addBtn.style.display = 'none';
    // Show the prompt
    deferredPrompt.prompt();
    // Wait for the user to respond to the prompt
    deferredPrompt.userChoice.then((choiceResult) => {
        if (choiceResult.outcome === 'accepted') {
          console.log('User accepted the A2HS prompt');
        } else {
          console.log('User dismissed the A2HS prompt');
        }
        deferredPrompt = null;
      });
  });
});

通知

通知を許可するように求めるダイアログを見たことがあるかもしれませんが,それのことです。

別のタブを見ていても通知が届くので,強力ですが煩わしいかもしれません。

そして,通知APIは2種類あります。
Notificationのほうは簡単に実装できますが,
Push通知のほうはサーバーを使う必要があるようで,複雑です。

なお,今回は実装していません。

カメラの使用

PWAの機能というわけではないのですが,端末の周辺機器を使うことはPWAに関連する事項なので,ここで紹介します。

getUserMediaでvideo要素をオンにして,カメラを利用しています。

ここで内カメラと外カメラのどちらを使うかや,カメラのアスペクト比を変えられるらしいのですが,うまくいかず。。

main.js

// video要素にカメラをストリーム?
function startCapture(opt) {
  if (video.srcObject != null) {
    // stop both video and audio
    stopButton.click();
  }

  let optionSetting = {
    video: true,
    audio: false,
    facingMode: null,
    width: {
      ideal: window.screen.width/2
    },
    height: {
      ideal: window.screen.height/2
    },
    aspectRatio: window.screen.width/window.screen.height
  };

  if (opt === 1) {
    optionSetting.video.facingMode = "environment";
  } else {
    optionSetting.video.facingMode = "user";
  }

  navigator.mediaDevices.getUserMedia(optionSetting)
  .then(function(stream) {
    video.srcObject = stream;
    video.play();
  })
  .catch(function(err) {
    console.log("An error occurred! " + err);
    document.body.innerHTML = 'Camera is NOT available!';
    window.stop();
  });
}

optionSettingがうまく動いていない模様…?

動体検知の処理部分について

ここは昔にPythonで動体検知スクリプトを作ってみたときのコードをベースにしました。
OpenCV.jsでは使えない関数(accumulateWeighted)がありましたが,なくても検知はできているのでおそらく問題ないです。

輪郭を検出し,それの画素?数を前のフレームと比べ,変化量を見ています。
変化量が閾値以上なら検知する仕組みです。

また,動きの検知はオンオフが激しいため,ある程度連続して動いた場合にだけ検知するようにしました。

SlackのWebhookを使って通知させる部分について

api.slack.com

動きを検知したら,Webhookを通してSlack botに通知させられるようにしました。

検知時の画像を表示させたかったのですが,画像はブラウザ上で一時的に表示させているだけなので無理でした。

通知メッセージはJSON形式で自由に設定できるのですが,わかりやすい例があったのでここで検証しながら作成しました。
いろいろなテンプレートを選べるので便利です。

main.js

function sendSlackNotify() {
  const data = {
    "blocks": [
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": "*Detected*\n" + new Date().toTimeString().replaceAll(' ', '_') + "\n"
        },
        "accessory": {
          "type": "image",
          "image_url": "https://" +document.location.host +"/move-capture-pwa/icon/icon64.png",
          "alt_text": "Detected camera image"
        }
      }
    ]
  }

  const option  = {
    "method": "POST",
    "body": JSON.stringify(data)
  }

  const url = document.getElementById('webhook-url').value;

  fetch(url, option)
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  })
  .then(data => console.log('Success!!:', data))
  .catch((error) => console.log('Error!!:', error));
  // SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data
}

コメントにあるように,なぜか構文エラーが出ちゃうんですよね。JSONの文法がミスってるのか…?まあ動くのでヨシ!

UIについて

面倒なのでほぼ触ってないです。


長くなってしまいましたが,今回は以上です。

はしがき

動作の軽量化やインタフェースの変更など,課題はたくさんあるので,また改善していきたいと思います。

では,これで2020年のアドベントカレンダーが無事終了となります。
皆さん,お疲れ様でした。ありがとうございました。
よいお年を!