動画中の物体の動きを検知する技術 (OpencvJSを利用したOptical flow)

動画に映る物体が動いたのか、どう動いたのかを検知するために利用されるOpticalflowという技術について紹介するとともに、OpenCV.jsと呼ばれるライブラリを利用したOptical flowの導入具体例について紹介・解説します。

はじめに

本稿では、動画に写る物体が動いたのか、どう動いたのかを検知するために利用されるOpticalflowという技術について紹介するとともに、OpenCV.jsと呼ばれるライブラリを利用したOptical flowの導入具体例について紹介・解説します。

目次

  1. Opticalflowの仕組み及び活用方法に関して
    1. Opticalflowの概要・アルゴリズム
    2. 具体的な活用方法の例
    3. 本技術を利用したデモについて
  2. OpenCV.jsを利用したOpticalflowの実践と解説
    1. OpenCV.jsの概要
    2. 導入方法
    3. 具体的な実装内容をもとに、利用方法・注意点を解説

Opticalflowの仕組み及び活用方法に関して

Opticalflowの概要・アルゴリズム

概要

Opticalflowとは一言でいうと、「2つの異なる画像間で、物体がどのように、どれくらい動いたのか、を捉えるための技術・計算方法」です。

Opticalflowのイメージ (出典)https://docs.opencv.org/3.4/d4/dee/tutorial_optical_flow.html

この技術を用いることで、上の例にあるように、動画(画像)の中で、何がどのように動いたのかを追跡したり、そもそも「何か」が動いたのかどうかを検知する、といったようなことが可能になります。

Opticalflowの仕組みについて少しだけ詳しく

そもそも動画とは、連続した一連の画像がまとまったもの、です。

動画の仕組み (出典)https://www.sugilab.net/jk/joho-kiki/2109/index.html

上の図を見ていただくとイメージできるように、動画とは連続した画像を高速で表示することでモノが動いているように見せている、というものです。

では、そうした複数の画像の間で何がどう動いたかを把握するためにはどうすればよいのでしょうか。

結論から申し上げると、「異なる画像の中で、同じ色彩を持っている要素(ピクセル)を比較し、その要素がどの方向に、どれくらい移動しているのか」を計算する、ことで「動き」の経過を計算・記録していく、という方法によってそれが可能になります。

Opticalflowの仕組み (出典)https://www.researchgate.net/figure/Calculation-of-optical-flow_fig1_262270679

その計算方法がまさにOpticalflowという技術なのですが、本稿ではアルゴリズム自体の詳細については省略し、別の記事(リンク)に譲ることとします。(下記記事に目を通していただくと、後述の具体例を利用した説明についてもより理解が用意になると思いますところ、お時間のある方は是非目を通してみてください。)

参考記事

具体的な活用方法の例

Opticalflowを利用すると、前述のとおり動画の中での物体の動きを検知・追跡することができるのですが、具体的には下記のような活用事例があります。

火災煙の検出

(出典)寺田 賢治・宮原 宏幸・新居 康俊 IEEJ Trans. IA, Vol.124, No.4, 200

→林野火災の早期発見のため、監視カメラ等を用いた火災煙の発生を自動で検知するシステム。

画像中の野鳥検出

(出典)久保山 裕・三田 長久・吉岡 俊英† FIT2007(第6回情報科学技術フォーラム)

→自然環境の評価を目的とし、環境の変化に敏感な野鳥の動向を監視するためのシステム

いずれも定点カメラを利用した監視システムという点で一致しておりますが、Opticalflowという技術が、人間が監視するには骨が折れるような環境・条件での定点観測という分野で主に力を発揮できる技術である、といった点がうかがえるかと思います。

本技術を利用したデモについて

当社ではOpticalflowの原理をより具体的に実感していただくため、簡易的なデモを用意いたしました。

本デモではウェブカメラまたはお手持ちの動画ファイルをインプットとして、Opticalflowの処理を加えて物体の動きを追跡した様子を表示するようなデモとなっております。

デモという性質上、定点カメラを前提としており、機能も限定しておりますが、原理上、様々な具体例に応じたカスタム化を行うことで大幅な精度向上も見込める、という点もまた、Opticalflowという技術の奥深さであると考えております。

個別具体的なデモやPoCのご希望がある場合、こちらからお問い合わせをお願いいたします。

OpenCV.jsを利用したOpticalflowの実践と解説

OpenCV.jsの概要

上記でご紹介した当社のデモでは、OpenCV.jsというライブラリを利用しています。 そもそもOpenCVとは画像処理のための汎用的なライブラリ(参考:当社サイト)ですが、OpenCV.jsとはそのOpenCVの機能をブラウザで扱えるように、Javascriptライブラリとして展開したものです。 ライブラリのより詳細な説明については、下記リンクから公式ドキュメントをご覧ください。

参考

導入方法

OpenCV.jsを利用するためには様々な方法がありますが、単に利用するのみであれば、

1.ビルド済みのファイルをダウンロードする

下記リンクの冒頭部分を参照

Using OpenCV.js
2.htmlファイルで読み込む

同ドキュメントの下段部分のソースコードを参照

という2つのステップを踏むだけで簡単に導入することが可能です。

ビルドの段階から行いたい、という場合も含め上記公式ドキュメントにすべて記載があります。

具体的な実装内容をもとに、利用方法・注意点を解説

それでは以下、我々が公開しているデモを題材とし、Opticalflow技術とOpenCV.jsの利用方法について、詳細に解説していきたいと思います。

上記のような処理を実現するためには、大まかに下記のような流れで実装を進めていく必要があります。

  • キャプチャーの取得とグレースケール化
  • Opticalflowの実行と移動ベクトル(U,V)の取得
  • 動いた部分の描画
  • 上記のループ処理

なお、実装内容の全体像については、本稿の最下部にソースコードを掲載しているため、そちらもご確認ください。

キャプチャーの取得とグレースケール化

下記2つのキャプチャー画像を取得し、それぞれをグレースケールに変換していく処理から始めます。

  • ①ビデオの入力の開始時点
  • ②開始時点から指定したミリ秒だけ遅れた時点
キャプチャーの取得
                            
        let video = document.getElementById("loaded_video"); 
        video.muted = true;
        const FPS = 30;
        video.play();
    
        let cap = new cv.VideoCapture(video);
            
        // take first frame of the video
        let frame1 = new cv.Mat(video.height, video.width, cv.CV_8UC4);
        cap.read(frame1);
                            
                        

・cv.VideoCapture()というのはOpenCVに備えられているキャプチャー用の関数です。この関数に引数として実際にキャプチャーを取得したhtml上のvideo要素を渡してあげるだけでキャプチャーが取得できます。

・cv.MatとはOpenCV特有のデータ形式ではありますが、これは単なるマトリクス(行列)データ形式です。OpenCVでは行列データを加工する場合にこのデータ形式を利用する必要があるのですが、第一〜第三の引数がそれぞれ、row, colums, channelとなります。channelについては特に注意すべき点であるため、後ほど追加で言及します。 ここでのframe1という変数を宣言している部分は、画像のデータを行列として扱えるように、データの「箱」を用意してあげている、といったイメージで捉えるとわかりやすいかもしれません。

cv.Matの形状 (出典)2D matrices with CvMat in OpenCV

・この時点ではframe1は特になんの情報ももたない空の行列データですが、この用意した「箱」に、キャプチャーした画像の内容(RBGA情報)を入れてあげることで画像の行列データとして利用することができます。 この部分の実装が、read関数を利用したcap.read(frame1)の部分となります。これで動画のフレームをキャプチャーした内容を、行列データとして扱えるようになりました(上記①)。

グレースケール化
                            
        let prvs = new cv.Mat();
        cv.cvtColor(frame1, prvs, cv.COLOR_RGBA2GRAY);
                            
                        

・まず、空の行列データの箱を用意し、prvsという変数に格納しています。

・その後、cvtColor関数を利用し、上記で読み込んだframe1の内容をグレースケールに変換した上で、prvsに格納しています。第一引数に変換元 、第二引数に変換後の行列データの箱、そして第三引数には具体的に変換したい方式(この場合はRGBAからグレースケールに)を指定します。 これで、キャプチャー画像をグレースケール化した行列データをprvsとして利用できるようになりました。

②指定ミリ秒遅れたキャプチャーの取得

①から指定したミリ秒分のみ遅れた時点でのキャプチャー画像を取得したものに対して上記と同様の処理を行い、nextという変数として扱えるようにしています (※実際にはビデオが再生されている間、processVideoという関数をsetTimeoutに渡し続けることによって上記を実現しています。詳細は本稿末尾のソースコード全体を参照してください。)。

Opticalflowの実行と移動ベクトル(U,V)の取得

さて、用意されたprvs及びnextの2つの連続する画像の行列データを利用して、実際にOpticalflowを計算するフェーズです。

本稿の冒頭で述べたとおり、Opticalflowとは「異なる画像の中で、同じ色彩を持っている要素(ピクセル)を比較し、その要素がどの方向に、どれくらい移動しているのか(=移動ベクトル(U,V))」を計算する技術です。

我々の実装では、この部分を具体的に下記のとおり実装しています。

                        
        cap.read(frame2);
        cv.cvtColor(frame2, next, cv.COLOR_RGBA2GRAY);
        cv.calcOpticalFlowFarneback(prvs, next, flow, 0.5, 3, 15, 3, 5, 1.2, 0);
        cv.split(flow, flowVec);
        // u,vはそれぞれ各ピクセルのx,y座標方向への移動距離
        let u = flowVec.get(0);
        let v = flowVec.get(1);
                        
                    
calcOpticalFlowFarneback関数

今回の実装では物体の動き全体を捉えたいので、calcOpticalFlowFarnebackという関数を利用し、すべてのピクセルに対してOpticalflowを求めています。 (※Opticalflowにはこれ以外にも様々な計算方法がありますが、詳細については下記リンクを参照ください。

OpenCV: Optical Flow
u,vベクトル

ここで取得しているu,vとは、移動の方向を表すベクトル値と捉えてください。

u,vベクトル (出典)Transformer Vibration Detection Based on YOLOv4 and Optical Flow in Background of High Proportion of Renewable Energy Access

画像はある点が単位時間内(dt)にどう動いたかを数式で表現していますが、u,vはそれぞれ、

  • u : dx / dt (単位時間内にx方向にどれくらい動いたか)
  • v : dy / dt (同上、y方向)
を表しています。

これらu,vは、上記、calcOpticalFlowFarneback関数を利用することで用意に取得することができます。

これでopticalflowを利用して、それぞれのピクセルの動きのベクトルを取得することができました。

それでは次に、取得したu,vを用いて、実際に描画を行っていく手法について解説いたします。

動いた部分の描画

ここでは上記で取得した結果(u,v)をもとに、動いた範囲を線で描画することで物体の動きを表現する方法について解説します。

実装部分は下記のとおりです。

                        
        // ピクセルを一定間隔で飛ばして描画を見えやすくする
        let step = 4;
        // // 描画する線の太さ
        let strokeWidth = 1;

        // ピクセルごとに処理
        for(let i = 1; i < videoWidth; i += step){
             for(let j = 1; j < videoHeight; j += step){
                   // x,yは原点の座標
                   let x = i*j  % videoWidth;
                   let y =  ~~(i*j / videoWidth);
                   // 移動距離(uがx座標、vがy座標方向への移動距離)
                   let thisU = u.data32F[i*j-1];
                   let thisV = v.data32F[i*j-1];
                   // magnitudeは移動距離の大きさ
                   let magnitude = Math.sqrt(thisU**2+thisV**2)
                   // 移動距離が一定以上の場合のみ描画する
                   if(magnitude > sensitivity){
                       // 描画する線の長さ(0~6の範囲のmagnitudeを0-1に正規化)
                       let lineLength = (magnitude / 6);
                       let p1  = new cv.Point(x, y);
                       let p2  = new cv.Point(x + thisU * lineLength, y + thisV * lineLength);
                       cv.line(src, p1, p2, [0, 255, 0, 255], strokeWidth)
                   }
               }
        }
        cv.imshow('canvasOutput', src);
                        
                    
移動距離(magnitude)の計算と感度設定

・ピクセルごとに動いたかどうかを判断し、処理を行います。

・移動した距離はu,vを利用して計算し、magnitudeという変数に格納します。移動距離が一定の場合のみ、描画処理を行うようにしています。

・なお、ここでのsensitivityという変数は、デモ画面上で設定可能な「感度」です。感度を操作することで描画処理を加える移動距離の大きさに自由度をもたせています。

移動部分の描画処理

・p1を原点座標、p2を移動後の座標としています。

・cv.line関数を利用し、原点座標と移動後座標を結ぶ線を描画してやります。

これで、動いた部分が緑色の線で描画することができました。この処理を一定の間隔ごとのピクセルに対して行います(全てのピクセルに行うと見づらくなるため。)。

描画後の画像

上記のループ処理

動画が再生されている間、フレームごとに上記の処理が行われるように、setTimeout関数を利用して、処理を繰り返してやります。

今回の実装の内容は以上となりますが、実装の全体像は下記のとおりです。

                    
function video_flow(isSample,index){
    removeCanvas();
    // ウェブキャムの内容が残らないよう、キャンバスの作成はこの段階で発動
    createCanvas();
    let video;

    if(isSample){
        video = document.getElementById("sample_video"+String(index+1)); 
    }else{
        video = document.getElementById("loaded_video"); 
    }
    video.muted = true;
    const FPS = 30;
    video.play();

    let cap = new cv.VideoCapture(video);
    let videoWidth = video.width;
    let videoHeight = video.height;
        
    // take first frame of the video
    let frame1 = new cv.Mat(videoHeight, videoWidth, cv.CV_8UC4);
    cap.read(frame1);
    
    // prvsはファーストフレームをグレースケール化したもの
    let prvs = new cv.Mat();
    cv.cvtColor(frame1, prvs, cv.COLOR_RGBA2GRAY);
    frame1.delete();

    let frame2 = new cv.Mat(videoHeight, videoWidth, cv.CV_8UC4);
    let next = new cv.Mat(videoHeight, videoWidth, cv.CV_8UC1);
    let flow = new cv.Mat(videoHeight, videoWidth, cv.CV_32FC2);
    let flowVec = new cv.MatVector();

    // 普通のビデオの描画用
    let src = new cv.Mat(videoHeight, videoWidth, cv.CV_8UC4);

    let videoPlaying = true;
    video.addEventListener('ended', (event) => {
        videoPlaying = false;
    });
    video.addEventListener('pause', (event) => {
        videoPlaying = false;
    });
    
    function processVideo() {
        // 感度は通常、高いほうが敏感に感知するという印象なので、反転させてあげるのがUIUX的にはよし
        // 画面上の設定値50=実際の設定値1となるように計算
        // 実際の設定値が1の場合、余計な雑音を無視したちょうどよいくらいになるので一旦この数値に設定している。
        // 画面上の設定値は0〜100としている(わかりやすさ重視で)
        let sensitivity = sensityivity_input.value;
        sensitivity = normalize(sensitivity);
        
        if(videoPlaying){
            try {
                let begin = Date.now();
    
                // start processing.(動いた部分の描画)
                // nextもファーストフレームをグレースケール化したものだが、prvsよりもdelay分だけ遅れているもの
                cap.read(frame2);
                cv.cvtColor(frame2, next, cv.COLOR_RGBA2GRAY);
    
                cv.calcOpticalFlowFarneback(prvs, next, flow, 0.5, 3, 15, 3, 5, 1.2, 0);
                cv.split(flow, flowVec);
                // u,vはそれぞれ各ピクセルのx,y座標方向への移動距離
                let u = flowVec.get(0);
                let v = flowVec.get(1);

                cap.read(src);

                // ピクセルを一定間隔で飛ばして描画を見えやすくする
                let step = 4;
                // // 描画する線の太さ
                let strokeWidth = 1;

                // ピクセルごとに処理
                for(let i = 1; i < videoWidth; i += step){
                    for(let j = 1; j < videoHeight; j += step){
                        // x,yは原点の座標
                        let x = i*j  % videoWidth;
                        let y =  ~~(i*j / videoWidth);
                        // 移動距離(uがx座標、vがy座標方向への移動距離)
                        let thisU = u.data32F[i*j-1];
                        let thisV = v.data32F[i*j-1];
                        // magnitudeは移動距離の大きさ
                        let magnitude = Math.sqrt(thisU**2+thisV**2)
                        // 移動距離が一定以上の場合のみ描画する
                        if(magnitude > sensitivity){
                            // 描画する線の長さ(0~6の範囲のmagnitudeを0-1に正規化)
                            let lineLength = (magnitude / 6);
                            let p1  = new cv.Point(x, y);
                            let p2  = new cv.Point(x + thisU * lineLength, y + thisV * lineLength);
                            cv.line(src, p1, p2, [0, 255, 0, 255], strokeWidth)
                        }
                    }
                }
                
                next.copyTo(prvs);

                cv.imshow('canvasOutput', src);
                
                // schedule the next process.
                let delay = 1000/FPS - (Date.now() - begin);
                setTimeout(processVideo, delay);
                    
                

最後に

一点、最後に注意点ですが、本稿で紹介したデモについては、あくまで基本的なOptical flowの概念を体験いただくためにご用意したものであり、様々なケースに関して汎用性があるとは必ずしも言えません。

例えば、本デモの場合、あくまで定点カメラの動画を前提としており、そのため、インプットされた動画のカメラ位置が動く場合、画面全体が「動いたもの」として検知されることになります。

実際には定点カメラでない場合、ないしは動く物体の方向や程度が事前にある程度予想できる場合など、様々なケースが考えられますが、そうしたケースにも個別に合わせて開発を行うことで、より良い精度を出すことは十分に可能です。

個別具体の案件についてのデモの実施や、PoCのご依頼等につきましては、下記のお問い合わせフォームからのご連絡をお願いいたします。