2007. 5. 22.
石立 喬

Visual C++ 2005 Express Editionの易しい画像処理(14)

―――原画像から赤を抽出し、一旦拡膨張させて収縮させる―――


 特定の色を有する物体を検出する方法と、検出後に生じるノイズの除去を紹介する。具体的には、赤色の交通標識を想定し、その検出の予備的処理に応用できる。

概要
 ここで紹介する内容は二種類の異なった画像処理からなる。最初に紹介するのは、原画像から、特定の色(ここでは赤)を持った物体を抜き出す方法で、天候や日照、順光や逆光の条件などによる色の変動を許容する必要があり、画像をRGBに分解して、その比率から特定の色を判定する。二番目は、上記で得られた画像から邪魔なノイズを除去する方法で、一旦膨張させて小さな穴を埋め、次に収縮させて元に戻すモーフィング処理を使用する。ただし、ここで用いた例では、顕著な効果は期待できなかった。

赤の検出方法
明暗に影響されずに、特定の色を検出するには、RGBをHSV(HSBや HSIなどとも呼ばれる)に変換して、hue(色相)、saturation(彩度)、value(明度、brightnessやintensityとも呼ばれる)で判断する方法があるが、これは面倒な割には、RGB成分の比率を用いる後述する方法と結果に差異があまり無い。
 R/GとR/Bの比率が同時に一定以上の値になる場合を赤とするのは、hue(色相)を選んでいることと似ており、操作が簡単である。具体的には、R>2*G、R>2*B、R>40 のすべての条件が揃った場合を赤とした。夜間のヘッドライトの反射による場合のように、全体が明るくなり過ぎて十分な比率が取れなくなった場合(彩度が低下する)には抽出できなくなる欠点があるが、HSV変換を用いる方法も、彩度の条件を下げる必要が生じるので、欠点にも大差は無い。

二値画像の膨張と収縮
 二値画像上のパターンを膨張させたり収縮させたりすると、原画像とは形状の異なったパターンが得られる。このように、形状が異なる変形をモーフィング(Morphing、Morphological Processing、形態学的処理)と言う。モーフィングというと、顔を変形することを連想するが、それに限ったものではない。
 モーフィングに含まれる処理には、膨張(Dilation)と収縮(Erosion、腐食)がある。ここでは、以後、背景色を白(論理的には0、明るさ濃度では255)、前景色を黒(論理的には1、明るさ濃度では0)として話を進める。膨張を用いると、切れていた線をつないだり、穴のあいた図形の穴を埋めたりすることができ、白ノイズの除去に役立つ。収縮は、小さな突起や小さな塊を除去することができ、黒ノイズの除去に役立つ。膨張も収縮も、過度に行うと画像が真っ黒になったり真っ白になったりする。膨張を実行してから収縮を行うものをクロージング(Closing)と言い、収縮を先に実行してから膨張を行うものをオープニング(Opening)という。

膨張のアルゴリズム
 「注目するピクセルが0の場合、その周辺の8個のピクセルが1個でも1であれば注目するピクセルを1に変える」のが膨張の基本原理であるが、これをこのまま何回も使用すると、膨張の効果が強すぎて、たちまち全画面が1になってしまう。そこで、ここでは、周辺ピクセルの1が5個(5/8であるから半数を超える)以上である時に限り注目するピクセルを1に変えるように緩和した。原画像の周辺部のピクセルに対しては、その周辺の8個のピクセルを使用できないので、周辺部の処理は省略した。

収縮のアルゴリズム
 「注目するピクセルが1の場合、その周辺の8個のピクセルが1個でも0であれば注目するピクセルを0に変える」のが収縮の基本原理であるが、これをこのまま何回も使用すると、全画面が0になってしまうので、周辺ピクセルの0が5個以下である時に限り注目するピクセルを0に変えることに緩和した。周辺部の処理は省略した。

プログラムの構成
 全体の流れのプログラムは、Form1_Paintに記述する。
◎Form1_Paint
 1) 原画像(320x240ピクセル)をファイルから読み込み、Bitmapクラスの画像bmap_srcとする。
 2) 赤を抽出した結果を格納する画像bmap_red、膨張させた画像のためのbmap_dil、収縮させた画像のためのbmap_eroを用意する。
 3) メソッドextractRedを呼び出し、原画像bmap_srcから赤色の部分を抽出し、二値画像化して画像bmap_redを得て、画面に表示する。
 3) 画像bmap_redを、メソッドdilateImageで膨張させ、膨張画像bmap_dilateを得て、表示する。
 4) 画像bmap_dilを、メソッドerodeImageで収縮させ、収縮画像bmap_erodeを得て、表示する。
◎extractRed
 1) 引数で与えられた画像bmap_inの幅と高さを取得し、同じサイズの出力用画像bmap_outを用意する。
 2)画像bmap_inの全ピクセルをスキャンし、各ピクセルのRGB情報を取得する。
 3)各ピクセルについて、R>2*G、R>2*B、かつR>40の場合には、赤であると判定し、出力用画像に1(黒のピクセル)を設定する。
 4)そうでない場合は、赤でないと判定し、出力用画像に0(白のピクセル)を設定する。
◎dilateImage
 1)引数で与えられた画像bmap_inの幅と高さを取得し、同じサイズの出力用画像bmap_outを用意する。
 2)画像bmap_inの全ピクセルをスキャンし、各ピクセルの情報(白か黒か)を取得して、二次元配列data[i,j]に格納する。
 3)膨張させたことを示すchange_flagがfalseのままになる(これ以上、膨張させるピクセルが無くなった)まで、下記を繰り返す。
   ・change_flagをfalseに設定する。
   ・周辺部を除く原画像のすべてのピクセルデータ配列data[i,j]をスキャンし、個々のピクセルの周辺の「1」の数を数える。
   ・周辺の「1」の数が5個以上あれば、更新用ピクセルデータ配列data1[i,j]に「1」を入れ、膨張させたことを示すフラグchange_flagをtrueに変える。
   ・そうでない場合は、更新用のピクセルデータ配列data1[i,j]に「0」を入れる。
   ・同じ処理を繰り返すために、data1[i,j]をdata[i,j]に移す。
 4)更新されたピクセルデータ配列data1[i,j]から画像bmap_outを生成し、メソッドの返り値とする。
◎erodeImage
 上記で、下記のように変更する。
   ・周辺部を除く原画像のすべてのピクセルデータ配列data[i,j]をスキャンし、個々のピクセルの周辺の「0」の数を数える。
   ・周辺の「0」の数が5個以上あれば、更新用ピクセルデータ配列data1[i,j]に「0」を入れ、収縮させたことを示すフラグchange_flagをtrueに変える。
   ・そうでない場合は、更新用ピクセルデータ配列data1[i,j]に「1」を入れる。

プログラム

private: System::Void Form1_Paint(System::Object^ sender, System::Windows::Forms::PaintEventArgs^ e) {

   int X0=10,Y0=10;
   int X1=340,Y1=10;
   int X2=10,Y2=260;
   int X3=340,Y3=260;

   int WIDTH=320,HEIGHT=240;

   Graphics^ gr=e->Graphics;

   //ファイルから画像を読み込み表示する
   Bitmap^ bmap_src=gcnew Bitmap("A:/sample.jpg");
   gr->DrawImage(bmap_src,X0,Y0,WIDTH,HEIGHT);

   //Bitmapクラスの各画像を用意する
   Bitmap^ bmap_red=gcnew Bitmap(WIDTH,HEIGHT);
   Bitmap^ bmap_dil=gcnew Bitmap(WIDTH,HEIGHT);
   Bitmap^ bmap_ero=gcnew Bitmap(WIDTH,HEIGHT);

   //赤の抽出を実行し表示する
   bmap_red=extractRed(bmap_src);
   gr->DrawImage(bmap_red,X1,Y1,WIDTH,HEIGHT);

   //膨張を実行し表示する
   bmap_dil=dilateImage(bmap_red);
   gr->DrawImage(bmap_dil,X2,Y2,WIDTH,HEIGHT);

   //収縮を実行し表示する
   bmap_ero=erodeImage(bmap_dil);
   gr->DrawImage(bmap_ero,X3,Y3,WIDTH,HEIGHT);

}

private: Bitmap^ extractRed(Bitmap^ bmap_in){

   int width=bmap_in->Width;
   int height=bmap_in->Height;

   Bitmap^ bmap_out=gcnew Bitmap(width,height);
   Color color1;
   Byte r,g,b;

   for(int i=0;i<width;i++)
      for(int j=0;j<height;j++){
         color1=bmap_in->GetPixel(i,j);
         r=color1.R;
         g=color1.G;
         b=color1.B;

         //RGBの比率で赤を抽出
         if(r>2*g && r>2*b && r>40)
            bmap_out->SetPixel(i,j,Color::Black);   //赤と判定
         else
            bmap_out->SetPixel(i,j,Color::White);   //赤以外と判定
      }

   return bmap_out;

}

private: Bitmap^ dilateImage(Bitmap^ bmap_in){

   int width=bmap_in->Width;
   int height=bmap_in->Height;
   int i,j,m,n;
   int count;
   Color color1;
   bool change_flag;
   Bitmap^ bmap_out=gcnew Bitmap(width,height);
   array<Byte,2>^ data=gcnew array<Byte,2>(width,height);
   array<Byte,2>^ data1=gcnew array<Byte,2>(width,height);

   //原画像を二次元配列データ化する
   for(i=0;i<width;i++)
      for(j=0;j<height;j++){
         color1=bmap_in->GetPixel(i,j);
         if(color1.R>127) data[i,j]=0;   //白
         else         data[i,j]=1;   //黒
      }

   //膨張を実行する
   data1=data;
   do{
      change_flag=false;
      data=data1;
      for(i=1;i<width-1;i++)
         for(j=1;j<height-1;j++){
            if(data[i,j]==1){
               data1[i,j]=1;
               continue;
            }
            //周辺3x3の「1」の個数を数える
            count=0;
            for(m=-1;m<=1;m++)
               for(n=-1;n<=1;n++)
                  if(data[i+m,j+n]==1) count++;

            if(count>=5) {  //「1」の個数が5以上だった
               data1[i,j]=1; //膨張させる
               change_flag=true;
            }
            else data1[i,j]=0;
      }
      data=data1;
   }while(change_flag);

   //二次元配列データを画像化する
   for(i=0;i<width;i++)
      for(j=0;j<height;j++){
         if(data1[i,j]==1) color1=Color::Black;
         else color1=Color::White;
         bmap_out->SetPixel(i,j,color1);
      }

   return bmap_out;

}

private: Bitmap^ erodeImage(Bitmap^ bmap_in){

   int width=bmap_in->Width;
   int height=bmap_in->Height;
   int i,j,m,n;
   int count;
   Color color1;
   bool change_flag;

   Bitmap^ bmap_out=gcnew Bitmap(width,height);
   array<Byte,2>^ data=gcnew array<Byte,2>(width,height);
   array<Byte,2>^ data1=gcnew array<Byte,2>(width,height);

   //原画像を二次元配列データ化する
   for(i=0;i<width;i++)
      for(j=0;j<height;j++){
         color1=bmap_in->GetPixel(i,j);
         if(color1.R>127) data[i,j]=0;    //白
         else data[i,j]=1;           //黒
      }

   //収縮を実行する
   data1=data;
   do{
      change_flag=false;
      for(i=1;i<width-1;i++)
         for(j=1;j<height-1;j++){
            if(data[i,j]==0){
               data1[i,j]=0;
               continue;
            }
            //周辺3x3の「0」の個数を数える
            count=0;
            for(m=-1;m<=1;m++)
               for(n=-1;n<=1;n++)
                  if(data[i+m,j+n]==0) count++;
            if(count>=5) {  //「0」の個数が5以上だった
               data1[i,j]=0; //収縮させる
               change_flag=true;
            }
            else data1[i,j]=1;
         }

      data=data1;
   }while(change_flag);
   
   //二次元配列データを画像化する
   for(i=0;i<width;i++)
      for(j=0;j<height;j++){
         if(data1[i,j]==1) color1=Color::Black;
         else        color1=Color::White;
         bmap_out->SetPixel(i,j,color1);
      }

   return bmap_out;

}


得られた結果
 図1の左上は、原画像、右上は赤の部分を抽出した二値画像、左下は、それを膨張(Dilation)処理した画像、右下は、その結果を更に収縮(Erosion)処理した画像である。膨張により、黒地の中の小さな白キズが除去され、収縮によって小さい黒ノイズが除去される。


図1 代表的な画面


 図2は、一部分が日陰になって暗くても、赤を正しく認識している例を示す。左は原画像、右は膨張処理をしてから収縮処理をした画像である。


図2 明度に関係なく赤を認識


 図3は、逆光の場合で、周辺部が明るい空になっているが、赤を正しく認識している例を示す。


図3 逆光の場合


 図4は、赤のノイズの多い場合で、形状などによる、さらなる選別が必要である。


図4 赤い物体が多数存在する場合


 図5は、夜間に自動車のライトで照らされた場合で、さすがに、反射が強く明るすぎて、通常の設定では赤を正しく認識できなかったので、標準の「R>2*G、R>2*B、R>40」 から、「R>1.2*G、R>1.2*B、R>40」に条件を変更して得た結果を示す。


図5 夜間にライトで照らし出された場合