どうもー シュモクザメです。
今日は前回に引き続き、画像処理における物体認識です。
↓が前回の記事のリンクです。
前回の記事のおさらいと更なる疑問点
前回の記事でやっとことを簡単にまとめると、
- 画像を閾値処理にて二値化
- 二値化した画像から、画像中の物体の重心を求める。
って感じですね。
二値化することで以下のように画像から物体の部分だけを認識することができるので、のちの重心計算が可能になっています。


ただここで疑問が生じます、、

もし複数の物体が写り込んでいたらどうなるの?
前回の記事のコードでは、画像の物体は一つであるってことが暗黙の仮定になっていました。
もし複数の物体が写っている画像にこのコードを使うと二値化した画像は

こんな感じになって、重心は

こんな感じ。
これは複数の物体を、一つの物体、とみた時の重心になってしまってます。
今回はこの問題を解決する、
つまり一つの画像から複数の物体を別々に認識することを目的とします。
前提
前提として僕はXcodeでコードを書いて、実行しています。
opencvはhomebrewからダウンロードして、xcodeからパスをつないだり必要なファイルなどをブチ込んだり、、、とバンバンに使える状態から始めています。
Xcodeにopencvを導入する方法はgoogkeで調べればいくらでもあるので、まだ導入できてない人はまずそこをお願いします。(気が向いたらこのサイトでもopencvの導入の記事を書くかも
複数物体の定義
コードの実装の前にここからやっときましょう。
この記事での複数物体って言うのは以下のような状況です。

各物体は塗りつぶされていて、物体領域中の画素同士が連結されているのがわかると思います。
それぞれの物体の間にはしっかりと、いずれの物体領域でもない空間(つまり背景)が存在していて、それぞれの物体を個別化できるようになっています。
今回はラベリングのアルゴリズムをメインでやりたいので、このようなガチガチの制約付きですがよろしくお願いします。
ラベリングのアルゴリズム
上で示した複数物体の定義のイメージは、
同じ物体は、その物体領域の中で画素同士が連結していて一つの島になっている。って感じです。
このイメージがあればこれから説明するアルゴリズムが理解しやすいと思います。
まず準備段階として、対象の画像の各画素に識別用のラベルを持たせます。
ラベルっていうのは値を持つ変数みたいなものです。それで、その各画素の識別用ラベルは最初全て0とします。
そこからその画像の画素を、左上から右下へとラスタ走査していき、注目している画素が物体の存在している画素である時に、以下の処理を行います。(コード実装上での物体が存在している画素というのは、二値化した時に閾値から物体と認識された画素のこと)
- 注目画素とその近傍8画素が全てラベル値0の場合、その注目画素に全ラベル値のなかの最大値より1大きい値をラベル値として与える。
- それ以外の場合、その注目している画素の近傍8画素の中で、0以外の最も小さいラベル値を注目画素に与える。
この処理を全ての画素に対して行い終えたら1セットとします。
その後、ラベル値を引き継いだままもう一度左上から右下へとラスタ走査しながら処理をを行います。(つまりさらに1セット)
このセットを繰り返していき、全ての画素に対してラベル値の変更がなかった時、そのセットで全体の処理を終えてラベリング完了です。
ラベリング完了時点で同じラベル値を持つ画素同士が同じ物体ってことになります。

うーん。これで物体が別々に認識できるイメージが湧かんな、、
具体的な図を用いて説明します。

マス目が一画素ってことです。それで灰色のところは物体が存在している画素ということにします。
定義通り、上の画像では2つの物体が存在してますね。
そして全てのマス目にオレンジで0とありますがこれはラベル値が全て0ってことを示してます。
早速左上からラスタ走査していきます。

上の画像の位置で初めて物体領域に入ります。ここではもちろん周りが全て0なので新しいラベル値1が与えられます。
そして次は隣の画素ですね。

ここでは先ほど更新したラベル値1が左隣にあり、それが近傍8画素の0以外で最も小さい値に該当するので、この注目画素のラベル値も1に変更します。
そしてさらにラスタ走査を進めていきます。

左上から2×2番目のところですね。ここは先ほどと同じ物体領域の中なので同じラベル値の[1]が欲しいところですが、上の画像で示した通り近傍8画素を囲う緑色の枠の中に1がありませんね。それどころか全て0なので新たなラベル値2が与えられます。
一方その隣の画素は

ここは緑色の枠が、最初にラベル値を与えた1に引っかかるので、この注目画素は1となります。
こんな感じで最後まで処理を続けて走査し終えると、、

こんな感じ。

下の物体はラベル値3で固められたけど、上の物体は左端だけラベル値が2ですね。
でも大丈夫です。
今回のラスタ走査1セット中にはラベル値の変更があったので、もう一度ラスタ走査を行う必要があります。
てことで同様に左上から初めて、2×2の位置につくと、、

今回は緑枠の中に1があるのでこの注目画素は2から1に更新されます。
てことでピッタリ揃いましたね。
このセットでは今行った変更があるので全体の処理は終えずにもう一セット行って、そのセットでは変更がないのでそこでラベリング終了です。
最終的なラベル値は上の画像のラベル値となります。
まあこんな感じで何回も繰り返せば最終的に同じ物体の画素は同じ値を持つことがわかると思います。てことで実装しましょう。
コードの実装
画像の読み書き
// カラー画像を読み込む
void Read_Color( const char *name, unsigned char img[HEIGHT][WIDTH][3] )
{
cv::Mat readImage( HEIGHT, WIDTH, CV_8UC3);
readImage = cv::imread(name, 1);
for( int w=0; w<WIDTH; w++ )
for( int h=0; h<HEIGHT; h++ )
for( int c=0; c<3; c++ )
img[h][w][c] = readImage.data[(w+h*WIDTH)*3+c];
}
// カラー画像をファイルへ保存
void Write_Color( const char *fname, unsigned char img[HEIGHT][WIDTH][3] )
{
cv::Mat IMAGE( HEIGHT, WIDTH, CV_8UC3 );
for( int w=0; w<WIDTH; w++ )
for( int h=0; h<HEIGHT; h++ )
for( int c=0; c<3; c++ )
IMAGE.data[(w+h*WIDTH)*3+c] = img[h][w][c];
cv::imwrite( fname , IMAGE);
}
二値化
void binary(unsigned char org[HEIGHT][WIDTH][3], unsigned char byn[HEIGHT][WIDTH]){
for(int w=0; w<WIDTH; w++){
for(int h=0; h<HEIGHT; h++){
int sum=0;
for(int c=0; c<3; c++)
sum += org[h][w][c];
if(sum/3 > t)
byn[h][w] = 255;
else
byn[h][w] = 0;
}
}
}
ここらへんは前回の記事で書いたので簡潔に。
今回は背景が薄めの色で物体が濃い色って仮定でやるので、閾値は画素値が高いものが引っかかって物体と認識されるようにしています。
まあよくわからなかったら前回の記事を読んでください!
ラベリングの関数
とりあえずコードです。
int k=1;
void labeling(unsigned char byn[HEIGHT][WIDTH], int label[HEIGHT][WIDTH]){
int flag=0;
do{
flag = 0;
for(int w=1; w<WIDTH-1; w++){
for(int h=1; h<HEIGHT-1; h++){
if(byn[h][w]==0){
int sum=0;
int min = k;
for(int i=-1; i<=1; i++){
for(int j=-1; j<=1; j++){
sum += label[h+i][w+j];
if(0<label[h+i][w+j]&&label[h+i][w+j]<=min)
min = label[h+i][w+j];
}
}
if(sum==0){
label[h][w] = k++;
flag++;
}else if(min == label[h][w])
;
else{
label[h][w] = min;
flag++;
}
}
}
}
}while(flag > 0);
}
めちゃくちゃややこしいんで少しずつやっていきましょう。
仮引数とグローバル変数k
bynは二値化した画像が入っている配列、labelは各画素のラベル値を格納する配列です。
関数外にあるkは新たなラベル値を与えるときに用いるグローバル変数kであり、最初は1にします。
繰り返し判定とdo_while
そして関数本文の最初のところ
int flag=0;
do{
flag = 0;
///処理///
}while(flag > 0);
do_while文内の処理が、上のアルゴリズムで説明した一セットです。
flagはそれを継続するか判定する変数で、ループ毎に0にリセットして、ラベル値変更があるたびにインクリメントします。
whileの継続条件がflag>0なのでラベル値変更があるセットではこの条件を満たすってことですね。
ラスタ走査の二重for文と物体領域判定
次の部分です。
for(int w=1; w<WIDTH-1; w++){
for(int h=1; h<HEIGHT-1; h++){
if(byn[h][w]==0){
///処理///
}
}
}
おなじみの二重forループですね。
範囲が1〜WIGTH-1と1〜HEIGTH-1となっていますが、これはのちに近傍8画素のラベル値を調べる際に、注目画素が画像の角や辺に位置していると存在しない近傍を調べてしまいます。
それを防ぐために1画素周り小さくラスタ走査しているって訳ですね。

範囲が小さくなった分影響が出るんじゃないの?
まあ小さいサイズだと目にわかるレベルで出るかもですが、今回想定しているサイズは640×480なのでまあ全然おkって感じですかね。
気になる場合は、if文で場合分けなどを作ればそのままのサイズバージョンも可能だと思います。
ただ今回は上の一回り小さいやつで行きます。
そんでif文ですね。
ここでは二値化した方の画像に注目して、その画素が物体領域かを判断しています。
0なら物体領域ってことなので、処理が始まります。
注目画素とその近傍8画素のラベル値を調べる
if文の中の前半の処理です。
int sum=0;
int min = k;
for(int i=-1; i<=1; i++){
for(int j=-1; j<=1; j++){
sum += label[h+i][w+j];
if(0<label[h+i][w+j]&&label[h+i][w+j]<=min)
min = label[h+i][w+j];
}
}
まずは最初の変数の定義から。
sumは注目画素とその近傍8画素全てのラベル値の和を格納する変数です。
minは注目画素とその近傍8画素のうち0ではない最も小さいラベル値を格納する変数です。最初はとりあえずkとしときます。(既についているラベル値がkより大きいことはまずないため)
そして次に、ここでも二重forループが出てきます。
ここは範囲がhとwの周りの縦横それぞれ-1から1の3×3の正方形ってイメージです。
つまり注目画素とその近傍8画素ってことです。
label[h+i][w+j]としてiとjを-1から1まで動かしています。
ここではsumにはlabel[h+i][w+j]を足し続けます。
一方でif文でlabel[h+i][w+j]が0より大きくてmin以下であるかを調べていきます。
もしそうならminをそれに置き換えます。
ラベル値の更新と継続判定
そしてif文の後半です。
if(sum==0){
label[h][w] = k++;
flag++;
}else if(min == label[h][w])
;
else{
label[h][w] = min;
flag++;
}
まず最初のif文でsumが0かどうかを調べます。
これが0なら新しいラベル値を与える場合に該当するのでlabel[h][w]をグローバル変数kに更新。その時にkはインクリメントしておきます。(こうすることで新しいラベル値を与えられる)
そんでラベル値が変更したのでflagをインクリメント。
次の判定はlabel[h][w]がminと同じ値かどうかです。
もしそうならラベル値を変更しない場合に該当するので、何もせずに処理を終えます。
最後はelse文。このときはlabel[h][w]をminに更新して、flagをインクリメントです。
これでdo_while文中身の処理が終わりました。
あとはflagの値で繰り返すかどうかだけです。
ラベリング自体はこれで完了してます!
ラベル値がiの物体の重心を計算する関数
ラベリングの時点で物体ごとにラベル値が割り振られてます。
その状態で、ラベル値がiの物体のみの重心を計算する関数を作成します。
void CoG2(unsigned char img[HEIGHT][WIDTH][3], int label[HEIGHT][WIDTH], int i){
double x_sum=0;
double y_sum=0;
double sum=0;
for(int h=0; h<HEIGHT; h++){
for(int w=0; w<WIDTH; w++){
if(label[h][w]==i){
sum += (label[h][w]/i);
x_sum += w*(label[h][w]/i);
y_sum += h*(label[h][w]/i);
}
}
}
int x = x_sum/sum;
int y = y_sum/sum;
for(int i=-2; i<=2; i++){
for(int j=-2; j<=2; j++){
img[(int)y+i][(int)x+j][0] = 0;
img[(int)y+i][(int)x+j][1] = 0;
img[(int)y+i][(int)x+j][2] = 0;
}
}
}
まず前提として重心は以下の計算式で計算できます。

ここでのfは入力した(x,y)が物体領域ならば1、そうでないならば0を返す関数です。
この式を軸にコードを作成しました。
仮引数と最初に定義する変数
void CoG2(unsigned char img[HEIGHT][WIDTH][3], int label[HEIGHT][WIDTH], int i){
double x_sum=0;
double y_sum=0;
double sum=0;
///処理///
}
仮引数のimgは元のカラー画像が入っている配列、labelは上の関数で作成したラベル値を格納した配列、iは今回の処理で重心を求める物体につけられたラベル値です。
x_sum、y_sum、sumはそれぞれ、重心計算の式の分母と分子を保存する変数です。
ラスタ走査
for(int h=0; h<HEIGHT; h++){
for(int w=0; w<WIDTH; w++){
if(label[h][w]==i){
sum ++;
x_sum += w;
y_sum += h;
}
}
}
int x = (int)x_sum/sum;
int y = (int)y_sum/sum;
はい、おなじみの二重forループでの画像のラスタ走査ですね。
ラスタ走査して行って、ラベル値がiなら上の式の計算を行います。
ラベル値がiってことは求めたい物体の領域ってことですからね。
if(label[h][w]==i)ならば式の計算を行います。
そして求めた分母分子から重心の座標を決定します。
このとき、座標は整数なのでint x,yとして、割り算もintでキャストします。
元画像の重心の位置に印をつける
for(int i=-2; i<=2; i++){
for(int j=-2; j<=2; j++){
img[(int)y+i][(int)x+j][0] = 0;
img[(int)y+i][(int)x+j][1] = 0;
img[(int)y+i][(int)x+j][2] = 0;
}
}
最後に重心の位置がわかるように重心の位置を中心に+-2の範囲の正方形を黒色にしときます。
main関数
int main(void){
unsigned char img[HEIGHT][WIDTH][3]={0};
Read_Color("test2.png", img);
unsigned char bin[HEIGHT][WIDTH] = {0};
binary(img,bin);
int label[HEIGHT][WIDTH] = {0};
labeling(bin, label);
int i=1;
int flag = 0;
while(i<=k){
flag = 0;
for(int w=0; w<WIDTH; w++){
for(int h=0; h<HEIGHT; h++){
if(label[h][w]==i){
printf("%d \n",i);
CoG2(img, label, i);
i++;
flag = 1;
}
}
}
if(flag==0)
i++;
}
Write_Color("bbb2.png", img);
Write("ccc2.png",bin);
}
とりあえず前半部分は順番に読み込み二値化ラベリングと行っています。
問題はここからですね。
複数物体の重心を求める処理
中盤のwhileです。
int i=1;
int flag = 0;
while(i<=k){
flag = 0;
for(int w=0; w<WIDTH; w++){
for(int h=0; h<HEIGHT; h++){
if(label[h][w]==i){
printf("%d \n",i);
CoG2(img, label, i);
i++;
flag = 1;
}
}
}
if(flag==0)
i++;
}
この部分の役割は、iをwhileでインクリメントして行って、物体領域のラベル値まで到達したらそこで重心計算の関数にそのiごと渡して元画像に印をつけていくって感じですね。
まずi=1とflag=0とします。
whileの条件はi<=kとします。(iはグローバル変数kより大きいことはないので)
whileに入るとまずflag=0とします。
ここからはラスタ走査です。その中でlabel[h][w]== iとなればその位置にラベル値がiの物体が初めて現れたってことなので、先ほど作った重心計算関数に渡します。
そのあとにiをインクリメントします。なんですぐにインクリメントするかっていうと、そのラベル値の物体の重心を二度計算することを防ぐためです。iが変われば引っかかりませんからね。
またfalgも1としときます。
そしてwhileの処理が最後まで行ったら、新しいiで物体を探索するためにiをインクリメントしてループの最初に、、
と思いきや、ここで簡単にインクリメントするのはだめです。
なぜなら仮にあるiで物体が存在したとしたら重心計算の処理が入り、その後iがインクリメントします。(i+1)
そのままラスタ操作が終わってここでまたインクリメントすると(i+2)、重心計算前の画素に対して(i+1)のラベル値を持つ物体が存在するか走査していないことになります。
これは物体の見落としに繋がるので対策をしましょう。
ってことで先ほどから言っているflagを使います。
flagが0ならwhile中でインクリメントしていないので、whileの最後でインクリメントします。
flagが1ならwhile中でインクリメントしているので、whileの最後でインクリメントせずに次のループに行きます。
全体のコード
#include <iostream>
#include <opencv4/opencv2/opencv.hpp>
#define WIDTH 640
#define HEIGHT 480
#define t 200
// カラー画像を読み込む
void Read_Color( const char *name, unsigned char img[HEIGHT][WIDTH][3] )
{
cv::Mat readImage( HEIGHT, WIDTH, CV_8UC3);
readImage = cv::imread(name, 1);
for( int w=0; w<WIDTH; w++ )
for( int h=0; h<HEIGHT; h++ )
for( int c=0; c<3; c++ )
img[h][w][c] = readImage.data[(w+h*WIDTH)*3+c];
}
// カラー画像をファイルへ保存
void Write_Color( const char *fname, unsigned char img[HEIGHT][WIDTH][3] )
{
cv::Mat IMAGE( HEIGHT, WIDTH, CV_8UC3 );
for( int w=0; w<WIDTH; w++ )
for( int h=0; h<HEIGHT; h++ )
for( int c=0; c<3; c++ )
IMAGE.data[(w+h*WIDTH)*3+c] = img[h][w][c];
cv::imwrite( fname , IMAGE);
}
void binary(unsigned char org[HEIGHT][WIDTH][3], unsigned char byn[HEIGHT][WIDTH]){
for(int w=0; w<WIDTH; w++){
for(int h=0; h<HEIGHT; h++){
int sum=0;
for(int c=0; c<3; c++)
sum += org[h][w][c];
if(sum/3 > t)
byn[h][w] = 255;
else
byn[h][w] = 0;
}
}
}
int k=1;
void labeling(unsigned char byn[HEIGHT][WIDTH], int label[HEIGHT][WIDTH]){
int flag=0;
do{
flag = 0;
for(int w=1; w<WIDTH-1; w++){
for(int h=1; h<HEIGHT-1; h++){
if(byn[h][w]==0){
int sum=0;
int min = k;
for(int i=-1; i<=1; i++){
for(int j=-1; j<=1; j++){
sum += label[h+i][w+j];
if(0<label[h+i][w+j]&&label[h+i][w+j]<=min)
min = label[h+i][w+j];
}
}
if(sum==0){
label[h][w] = k++;
flag++;
}else if(min == label[h][w])
;
else{
label[h][w] = min;
flag++;
}
}
}
}
}while(flag > 0);
}
void CoG2(unsigned char img[HEIGHT][WIDTH][3], int label[HEIGHT][WIDTH], int i){
double x_sum=0;
double y_sum=0;
double sum=0;
for(int h=0; h<HEIGHT; h++){
for(int w=0; w<WIDTH; w++){
if(label[h][w]==i){
sum += (label[h][w]/i);
x_sum += w*(label[h][w]/i);
y_sum += h*(label[h][w]/i);
}
}
}
int x = x_sum/sum;
int y = y_sum/sum;
for(int i=-2; i<=2; i++){
for(int j=-2; j<=2; j++){
img[(int)y+i][(int)x+j][0] = 0;
img[(int)y+i][(int)x+j][1] = 0;
img[(int)y+i][(int)x+j][2] = 0;
}
}
}
int main(void){
unsigned char img[HEIGHT][WIDTH][3]={0};
Read_Color("test2.png", img);
unsigned char bin[HEIGHT][WIDTH] = {0};
binary(img,bin);
int label[HEIGHT][WIDTH] = {0};
labeling(bin, label);
int i=1;
int flag = 0;
while(i<=k){
flag = 0;
for(int w=0; w<WIDTH; w++){
for(int h=0; h<HEIGHT; h++){
if(label[h][w]==i){
printf("%d \n",i);
CoG2(img, label, i);
i++;
flag = 1;
}
}
}
if(flag==0)
i++;
}
Write_Color("bbb2.png", img);
Write("ccc2.png",bin);
}
実際に使ってみた
上でも紹介した画像に対して処理を行うと、、、


各物体に対しての重心が黒色で示されているのがわかりますね。おk
まとめ
長くなりましたが以上でこの記事は終わりです。
後半に関しては重心を計算するようなプログラムにしていますが、ラベリングさえ完了していれば様々な活用ができます。
ぜひラベリングのみでも良いので活用ください!
ではまた〜
コメント