サイトアイコン CV & Technologies

移動する物体を追跡する

動画からダンゴムシの動きをトラッキングしています。このチュートリアルを実施すれば、他の多くの問題に対しても自身で解決できる能力を身につけられるでしょう。画像の2値化から輪郭検出、動画の書き出しまで広い内容を含んでいます。

手元に良い感じのサンプル動画がなかったため、庭先でダンゴムシを撮影してきました。今回はこの動画を解析します。動画は何でも良いのですが、背景が均一で追跡した物体とのコントラストの差ができるだけ大きいようにするのが良いでしょう。今回の動画は背景とのコントラストの差が小さくダンゴムシ追跡の難易度は少し高めです。

最終的な目的はダンゴムシの追跡ですが、まずは動画を再生して表示するだけのプログラムを作成します。動画へのパスは各々で動画の置いてある場所に合わせてください。

#include "opencv/cv.h"
#include "opencv/highgui.h"
using namespace cv;

int main(){
    Mat img;
    VideoCapture cap("pillbug.mp4");
    int max_frame=cap.get(CV_CAP_PROP_FRAME_COUNT);
    for(int i=0; i<max_frame;i++){ cap>>img;
        imshow("Video",img);
        waitKey(1);
    }
    return 0;
}

 

ダンゴムシが地面より黒いことを利用する

適当な閾値を設定し、動画の各フレームを2値化してみましょう。私がやってみたところ、この動画では閾値は固定70くらいがちょうど良さそうでした。当然ですが、この値は動画によって異なります。最後に、表示されたウィンドウのスクリーンショットを載せています。ダンゴムシのいる部分は黒く、他の部分は白くなっていますが、右下の部分で若干のノイズがみられ黒くなっている部分が見えます。したがって、今の時点で黒い部分をダンゴムシであるとは言えませんが、大きくかたまった黒い領域はダンゴムシのいる部分だけですので検出することができそうです。

#include "opencv/cv.h"
#include "opencv/highgui.h"
using namespace cv;

int main(){
    Mat img;
    Mat gray_img; //追加
    Mat bin_img; //追加
    VideoCapture cap("pillbug.mp4");
    int max_frame=cap.get(CV_CAP_PROP_FRAME_COUNT);
    for(int i=0; i<max_frame;i++){ cap>>img;
        cvtColor(img, gray_img, CV_BGR2GRAY);  //追加 グレースケールに変換
        threshold(gray_img,bin_img,70,255,THRESH_BINARY);  //追加 閾値80で2値画像に変換
        imshow("Video",bin_img); //変更
        waitKey(1);
    }
    return 0;
}

 

OpenCVは2値画像では白をモノとして認識する

ダンゴムシは黒色でした。そのため、2値化しても黒くなっています。OpenCVに限らずほとんどの画像処理ライブラリは2値画像においては白をモノとして認識します。したがって、後々の処理の際に便利になるので、この時点で色を反転し、ダンゴムシを白にしてしまいましょう。色の反転は簡単です。チルダ「~」をMat型の前につけると反転した画像になります。下の例のようにします。

Mat img = imread("sample.png", IMREAD_UNCHANGED);
img = ~img; //反転したimgをimgに入れる

 

膨張処理

ダンゴムシの動画を2値化したときのダンゴムシの部分を拡大したのが下の画像の左です。中央の画像はさらに色反転を行ったものです。このまま輪郭を検出することにより、ダンゴムシの認識をしても良いのですが、左側に白い部分が欠けている部分があるため、おそらく楕円形状と認識することはできません。よって、この穴を埋める処理、すなわち、白い部分を膨らませる処理を行います。これはdilate関数により実装されています。今回の動画では3回、この膨張処理を行うことで穴を無くしています(下の画像右)。ただし、膨張処理をした後の画像から面積などを求めるのは不適当であるということには注意してください。文字通り、大きくなっています。

#include "opencv/cv.h"
#include "opencv/highgui.h"
using namespace cv;

int main(){
    Mat img;
    Mat gray_img;
    Mat bin_img;
    Mat element = Mat::ones(3,3,CV_8UC1); //追加 3×3の行列で要素はすべて1 dilate処理に必要な行列
    VideoCapture cap("pillbug.mp4");
    int max_frame=cap.get(CV_CAP_PROP_FRAME_COUNT);
    for(int i=0; i<max_frame;i++){ cap>>img;
        cvtColor(img, gray_img, CV_BGR2GRAY);
        threshold(gray_img,bin_img,70,255,THRESH_BINARY);
        //追加ここから
        bin_img=~bin_img; //色反転
        dilate(bin_img, bin_img, element, Point(-1,-1), 3); //膨張処理3回 最後の引数で回数を設定
        //追加ここまで
        imshow("Video",bin_img);
        waitKey(1);
    }
    return 0;
}

 

findContours関数で輪郭を検出する

輪郭の内側の面積を計算し、画像内で最も大きいものをダンゴムシとして識別することにします。OpenCVでは輪郭は点の集合として保持されます。輪郭を構成する点の重心を計算することで近似的なダンゴムシの重心座標を求めます。findContours関数は2値画像を入力に使い、すべての輪郭、すなわち、すべての白と黒の境界線を検出します。それゆえ、検出される輪郭は複数になります。さらに、いくつの輪郭が検出されるかはプログラムを実行してみるまでわからないため可変長配列である必要があります。したがって、標準ライブラリのvectorを使うことになります。さらにややこしいことに各輪郭を構成する点もいくつかわからないので、これにもvectorを使うことになります。したがって、vectorの入れ子構造になります。初めはわかりにくいと思いますが、こういうものとして覚えてもらっても実質上の問題はありません。輪郭を保持する入れ物は下のように宣言します。これをfindContour関数に渡すと、輪郭を検出してこの中に点の集合として輪郭の情報を入れて戻してくれます。

vector<vector<Point> > contours;
findContours(bin_img, contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE);

 

findContours関数のオプション

findContour関数の第三引数は輪郭の検出方法を指定します。上の例のCV_RETR_EXTERNALは一番外側の白の輪郭のみを取得することを意味します。他にも多くの種類がありますが、もう一つよく使うのはCV_RETR_LISTだと思います。白の輪郭、黒の輪郭、内側、外側関係なく、すべての輪郭が取得されます。第四引数には輪郭点の格納方法を指定します。CV_CHAIN_APPROX_NONEはすべての点を格納するため任意の隣接する2点は互いに8近傍内に存在します。CV_CHAIN_APPROX_SIMPLEというものもあり、これは水平・垂直・斜めの線分を圧縮し、それらの端点のみを残します。すなわち、直線で表せる分に関してデータを圧縮します。

面積計算

findContour関数はすべての輪郭を取得するためどれがダンゴムシの輪郭であるかは推定する必要があります。ここでは、最大の輪郭内面積を持つ輪郭をダンゴムシの輪郭と推定することにしましょう。輪郭内の面積はcontourArea()関数で計算することができます。下の例では、最大の面積を持つ輪郭を特定し、max_area_contourという整数型の変数に保持させています。なお、輪郭はvectorを使った構造に保持されているため、任意番目の輪郭にat()を使ってアクセスできます。

vector<vector<Point> > contours;
findContours(bin_img, contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE);
double max_area=0;
int max_area_contour=-1;
for(int j=0;j<contours.size();j++){
    double area=contourArea(contours.at(j));
    if(max_area<area){
        max_area=area;
        max_area_contour=j;
    }
}

 

重心計算

ダンゴムシと思われる輪郭を推定したところで、次にその位置を知りたいので重心を求めます。簡単な方法としては楕円フィッティングを行って楕円の中心を求める方法などが考えられますが、楕円フィッティングは楕円形状に輪郭が検出されていない場合に重心とはかけ離れた座標を計算してしまうことがあります。そこで、今回は輪郭を構成するすべての点の重心を計算することにしましょう。重心は各点のx座標、y座標それぞれについての平均をとれば求まります。輪郭を構成する点の数はvectorの大きさであるのでsize()で求めることができます。これによって求めた、輪郭を構成する点の数をn個とすると、重心x座標はすべての点のx座標を足し合わせ、最後にnで割れば平均となるので計算できます。重心y座標も同様に求めます。なお、OpenCVでは近似ではなく、数学的に正しい重心を計算することも可能です。厳密な重心の求め方はこちら。

下の例では、求めた座標を中心とする半径50の円を元の動画に描画しています。それに伴い、表示する画像も2値画像ではなく元の画像に円を描画したものに変更しています。表示される動画のスクリーンショットは下図のようになります。

#include "opencv/cv.h"
#include "opencv/highgui.h"
#include <vector> //追加
using namespace cv;
using namespace std; //追加

int main(){
    Mat img;
    Mat gray_img;
    Mat bin_img;
    Mat element = Mat::ones(3,3,CV_8UC1);
    VideoCapture cap("pillbug.mp4");
    int max_frame=cap.get(CV_CAP_PROP_FRAME_COUNT);
    for(int i=0; i<max_frame;i++){ cap>>img;
        cvtColor(img, gray_img, CV_BGR2GRAY);
        threshold(gray_img,bin_img,70,255,THRESH_BINARY);
        bin_img=~bin_img;
        dilate(bin_img, bin_img, element, Point(-1,-1), 3);
        //追加ここから
        vector<vector<Point> > contours;
        findContours(bin_img, contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE);
        
        double max_area=0;
        int max_area_contour=-1;
        for(int j=0;j<contours.size();j++){
            double area=contourArea(contours.at(j));
            if(max_area<area){
                max_area=area;
                max_area_contour=j;
            }
        }
        
        int count=contours.at(max_area_contour).size();
        double x=0;
        double y=0;
        for(int k=0;k<count;k++){
            x+=contours.at(max_area_contour).at(k).x;
            y+=contours.at(max_area_contour).at(k).y;
        }
        x/=count;
        y/=count;
        
        circle(img, Point(x,y),50, Scalar(0,0,255),3,4);
        //追加ここまで
        imshow("Video",img); //変更
        waitKey(1);
    }
    return 0;
}

 

座標をファイルに出力する

さて、いよいよダンゴムシの追跡も最終回です。あとで様々な分析に使えるように追跡データをファイルに出力しましょう。今回は1フレーム毎に座標を書き出すことにします。時間解像度がそこまで必要なければ、何フレームかに1回の割合で出力するなどして間引きすることも考えてみてください。fprintf関数によりファイルに出力します。今回はコンマ区切りでフレーム番号、x座標、y座標をこの順で出力することにします。変数xと変数yはdouble型で定義していましたが、整数型で出力するようにしたいためint型に型変換します。変数名の前に(int)とつけることで型変換できます。フレーム番号iは整数型なのでそのまま使えます。

fprintf(fp, "%d,%d,%d\n",i,(int)x,(int)y);

 

動画の書き出し

せっかく分析を行ったので、追跡の様子を動画に出力しましょう。下の例ではダンゴムシ追跡の様子を動画として出力し、画像出力のサンプルコードとして20フレーム目を画像として出力しています。出力結果の画像と動画、テキストファイルを掲載しています。現状ではOpenCVはmp4を出力できませんのでavi形式で出力していますが、より多くのブラウザで表示させるために別のソフトウェアでmp4に変換しています。

テキストファイル

#include "opencv/cv.h"
#include "opencv/highgui.h"
#include <vector>
#include <stdio.h> //追加
using namespace cv;
using namespace std;

int main(){
    Mat img;
    Mat gray_img;
    Mat bin_img;
    Mat element = Mat::ones(3,3,CV_8UC1);
    VideoCapture cap("pillbug.mp4");
    int max_frame=cap.get(CV_CAP_PROP_FRAME_COUNT);
    //追加ここから
    int v_w=cap.get(CV_CAP_PROP_FRAME_WIDTH); //縦の大きさ
    int v_h=cap.get(CV_CAP_PROP_FRAME_HEIGHT); //横の大きさ
    VideoWriter writer("pillbug.avi", CV_FOURCC_DEFAULT, 30,cvSize(v_w, v_h), true);
    char filename[] = "output.txt";
    FILE* fp;
    fp = fopen(filename, "w");
    //追加ここまで
    for(int i=0; i<max_frame;i++){ cap>>img;
        cvtColor(img, gray_img, CV_BGR2GRAY);
        threshold(gray_img,bin_img,70,255,THRESH_BINARY);
        bin_img=~bin_img;
        dilate(bin_img, bin_img, element, cv::Point(-1,-1), 1);
        dilate(bin_img, bin_img, element, cv::Point(-1,-1), 1);
        dilate(bin_img, bin_img, element, cv::Point(-1,-1), 1);
        vector<vector<Point> > contours;
        findContours(bin_img, contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE);
        double max_area=0;
        int max_area_contour=-1;
        for(int j=0;j<contours.size();j++){
            double area=contourArea(contours.at(j));
            if(max_area<area){
                max_area=area;
                max_area_contour=j;
            }
        }
        int count=contours.at(max_area_contour).size();
        double x=0;
        double y=0;
        for(int k=0;k<count;k++){
            x+=contours.at(max_area_contour).at(k).x;
            y+=contours.at(max_area_contour).at(k).y;
        }
        x/=count;
        y/=count;
        circle(img, Point(x,y),50, Scalar(0,0,255),3,4);
        imshow("Video",img);
        //追加ここから
        fprintf(fp, "%d,%d,%d\n",i,(int)x,(int)y);
        writer<<img;
        if(i==20){
            imwrite("pillbug-output.png", img);
        }
        //追加ここまで
        waitKey(1);
    }
    fclose(fp); //追加
    return 0;
}

 

輪郭線を描画する

前回はダンゴムシの重心座標を中心とする円を描画していましたが、今回はダンゴムシの輪郭線を描画することにします。膨張・収縮処理を施した後なので、ぴったりの輪郭線が描画できるかどうかはわかりません。今回はdrawContours関数を用います。この関数は、vector<vector >型の輪郭情報と描画するMatの名前、描画する輪郭の色や太さを引数に取ります。次のように記述した場合、imgという名前のMatに対して、contours_subsetに含まれる輪郭を太さ4の線で描画することを指示します。

drawContours(img,contours_subset,-1,Scalar(0,0,255),4);

 

太さの部分を負の数にすると、塗りつぶしで描画することを指示します。

drawContours(img,contours_subset,-1,Scalar(0,0,255),-1);

 

参考までに、ダンゴムシの輪郭を太さ1、4、-1で指定した場合には次のようになります。

前回の座標のファイル出力と動画の書き出しから追加したのは3行のみです。

vector<vector<Point> > contours_subset;
contours_subset.push_back(contours.at(max_area_contour));
drawContours(img,contours_subset,-1,Scalar(0,255,0),4);

 

まず、描画したい輪郭のインデックスがmax_area_contourであるのがわかっています。そこで、描画したい輪郭のみを保持するためにcontours_subsetという名前の、vector<vector >型の構造体を定義しています。この構造体の宣言は動画のループの中で行っているため、フレームが新しくなるごとに内容はリセットされることに注意してください。次に、実際に描画したい輪郭の情報である、contours.at(max_area_contour)push_back関数によりcontours_subsetに追加しています。今回は描画する輪郭がひとつだけですが、場合によってはcontours_subsetに複数の輪郭をpush_backすることで複数の輪郭を描画できます。最後にdrawContours関数にcontours_subset、描画するMatの名前、描画する輪郭の色、太さの値を入れて輪郭を描画します。

#include "opencv/cv.h"
#include "opencv/highgui.h"
#include <vector>
#include <stdio.h>
using namespace cv;
using namespace std;

int main(){
    Mat img;
    Mat gray_img;
    Mat bin_img;
    Mat element = Mat::ones(3,3,CV_8UC1);
    VideoCapture cap("pillbug.mp4");
    int max_frame=cap.get(CV_CAP_PROP_FRAME_COUNT);
    
    int v_w=cap.get(CV_CAP_PROP_FRAME_WIDTH);
    int v_h=cap.get(CV_CAP_PROP_FRAME_HEIGHT);
    VideoWriter writer("pillbug.avi", CV_FOURCC_DEFAULT, 30,cvSize(v_w, v_h), true);
    char filename[] = "output.txt";
    FILE* fp;
    fp = fopen(filename, "w");
    
    for(int i=0; i<max_frame;i++){ cap>>img;
        cvtColor(img, gray_img, CV_BGR2GRAY);
        threshold(gray_img,bin_img,70,255,THRESH_BINARY);
        bin_img=~bin_img;
        dilate(bin_img, bin_img, element, cv::Point(-1,-1), 1);
        dilate(bin_img, bin_img, element, cv::Point(-1,-1), 1);
        dilate(bin_img, bin_img, element, cv::Point(-1,-1), 1);
        vector<vector<Point> > contours;
        findContours(bin_img, contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE);
        double max_area=0;
        int max_area_contour=-1;
        for(int j=0;j<contours.size();j++){
            double area=contourArea(contours.at(j));
            if(max_area<area){
                max_area=area;
                max_area_contour=j;
            }
        }
        int count=contours.at(max_area_contour).size();
        double x=0;
        double y=0;
        for(int k=0;k<count;k++){
            x+=contours.at(max_area_contour).at(k).x;
            y+=contours.at(max_area_contour).at(k).y;
        }
        x/=count;
        y/=count;
        circle(img, Point(x,y),50, Scalar(0,0,255),3,4);

        //追加ここから

        vector<vector<Point> > contours_subset;
        contours_subset.push_back(contours.at(max_area_contour));
        drawContours(img,contours_subset,-1,Scalar(0,255,0),4);

        //追加ここまで

        imshow("Video",img);
        
        fprintf(fp, "%d,%d,%d\n",i,(int)x,(int)y);
        writer<<img;
        if(i==20){
            imwrite("pillbug-output.png", img);
        }
        
        waitKey(1);
    }
    fclose(fp);
    return 0;
}

 

モバイルバージョンを終了