2007. 5. 9.
石立 喬

Visual C++ 2005 Express Edition を用いた易しい画像処理(13)

――――エッジ検出と画像ファイルの読み書き ――――


 画像処理では、エッジ検出が良く用いられる。文字や物体を認識するには、それらの特徴を認知する必要があり、エッジは物体の形状を良く表すからである。

概要
 エッジ検出の手法は、画像処理の基本であり、多くの文献に詳述されている。最も広く用いられているのはSobelオペレータであるが、閾値を小さくすればエッジが太くなり、閾値を大きくすればエッジが検出されない。ここではPrewitt、Sobel、Laplacianを比較して表示するプログラムを紹介する。
原画像をファイルから読み込んでBitmapクラスの画像とし、エッジ検出処理で得られたBitmapクラスの画像を任意の画像形式でファイルに格納する。

エッジ検出のアルゴリズム
 エッジ検出は、画像の空間的な濃度変化を求めるもので、一般には一次微分が用いられる。具体的には、3x3のマトリックスをオペレータとして原画像に掛け合わせ、合計を求める(積和演算を行うコンボリューション操作)。一次微分オペレータとしては、Prewitt、Sobelなどが良く知られている。二次微分を用いるものにLaplaceがあるが、二次微分のため、正負の出力がペアで得られ、単に絶対値をとるとエッジが二本になる場合がある。Laplaceの出力のゼロ点(Zero-Crossing Point、正負のペア出力の中点)を求めると、真のエッジが得られるが、処理が面倒な割には効果が少ない。
 微分操作は、微細なノイズを強調する欠点があるので、あらかじめ原画像を平滑化しておいたり、オペレータに平滑化の機能を併せ持たせたりする。

カーネル(オペレータ)のいろいろ
 オペレータは、カーネル(kernel、核心)とも呼ばれる。ここで使用したカーネルを図1に示す。


図1 使用したカーネル


 上に示したカーネルで、左側のPrewittとSobelは一次微分オペレータで、横方向のエッジ(水平方向の急激な変化)に強く反応する。Prewittは原理的に最も分かりやすく、適度な平滑化機能も含まれている。Sobelは中心部の重みを少し大きくして、対象とするピクセル付近に重点をおき、平滑化の効果を抑えている。実際には、これらを90°回転させたカーネルも使用して縦方向のエッジ(垂直方向の急激な変化)も検出し、縦横両方のエッジ出力を加算したり、大きい方を選んだりする。右端は二次微分のLaplacianで、一つのカーネルで縦横両方向に同時に作用する。

エッジの検出と閾値の決め方
 カーネルを用いたコンボリューションは、RGBの各成分についてそれぞれ実行する。コンボリューション結果は負を含む値になるので、絶対値をとり、RGB各成分のエッジの強さの平均をとる。これを一定の閾値と比較して、それを超えた部分をエッジと判断する。ここでは、試行錯誤の結果、画像全体に対して求めたエッジ出力の最大値の1/8を閾値とし、それを超えたものをエッジと判定して、ノイズを除去した。

画像ファイルの読込みと格納について
 画像ファイルからBitmapクラスへの読込み、Bitmapクラスの画像から画像ファイルへの書き出しは、非常に簡単である。JPG、PNG、GIF、BMPのいずれの形式の画像ファイルからでも原画像を読み込むことができ、いずれの形式でもエッジ検出結果を保存できる。簡単にするために、扱う画像のサイズを320x240に固定してある。

プログラムの構成
◎Form1_Load
 Bitmapクラスの各オブジェクトをインスタンス化し、原画像を読み込むbmp_src以外は、すべて白色に初期化する。これは、エッジ検出のためのコンボリューションを、原画像の1ピクセル内側以内でしか行わないため、結果として得られる画像の周辺が未設定になるのを防ぐためである。
◎Form1_Paint
1) 最初にこのプログラムを実行した時は、flag_src(原画像を読み込むとtrue)もflag_edge(エッジ検出処理を終わるとtrue)も共にfalseのため、何もしない。
2) 原画像を読み込んでflag_srcがtrueになると、下記のエッジ検出処理を実行する。
    ・原画像bmap_srcからピクセルデータ配列color_dataを作成する。
    ・Prewitt、Sobel、Laplacianの順にmakeConvolutionメソッドを呼び出す。
    ・Laplacian以外では、メソッドの返り値の大きい方を採り、その値を二次元配列densityに格納する。
    ・全画面を通しての最大値max_dを取得し、これを8で割って、エッジ判定の閾値とする。
    ・二次元配列densityを再度読み込み、閾値より大きければ黒、小さければ白として、bmap_prewittなどに書き込む。
    ・flag_srcをfalseに設定して、再度のエッジ検出を止めさせる。
    ・flag_edgeをtrueに設定して、エッジ検出が終わったので、結果を画面に表示するようにする。
  3)flag_edgeがtrueであると、各画像をフォーム上に表示する。
◎menuOpenFile_Click
 ツールボックスから「OpenFileDialog」を選択し、フォーム上でクリックすると、コンポーネントトレイにopenFileDialog1ができるので、それを使用してファイルから各種の画像ファイルを読み込み、Bitmapクラスの画像データとするためのプログラムを記述する。読込みが終わると、flag_srcをtrueにし、this->Refresh()でForm1_Paintを呼び出す。
◎menuSaveFile_Click
 ツールボックスから「SaveFileDialog」を選択し、フォーム上でクリックすると、コンポーネントトレイにsaveFileDialog1ができるので、それを使用する。selectionによって、どの画像をファイルに保存するかが指定され、保存する画像フォーマットの種類が、ダイアログボックスのFilterIndexで与えられるので、それらに従って、Bitmapクラスの画像データをファイルに出力する。ダイアログボックスが閉じた後に、赤枠の一部が残ることがあるので、this->Refresh()のよって、念のため再描画する。
◎makeConvolution
 i、jは、それぞれ-1、0、1と変化させ、原画像の対象とするピクセル位置x、yに対して、x-1からx+1まで、y-1からy+1までの範囲をスキャンする。これに対して、カーネルは、0から2まで、0から2まで変化させるので、
   color[x+i,y+j].R*kernel[i+1,j+1]
のように記述して積和演算を行う。R成分の結果sum_r、G成分の結果sum_g、B成分の結果sum_bのそれぞれ絶対値をとり、平均をとって価を返す。
◎Form1_MouseDown
 ファイルに保存する画像を選択するために用いる。マウスをフォームの上でクリックすると、座標がPoint構造体のpoint1に設定される。どの画像の領域にあるかを判定するには、rectangle1.Contains(point1)などを用いる。このメソッドは、矩形範囲rectangle1の内部にマウスクリック点point1があればtrueを返す。
 選択された画像の種類は、selectionに設定される。この値は、ファイルに保存する時の画像の指定と、画像の周辺に赤枠を表示するために使用する。

プログラム
 ファイルへの保存時の画像フォーマットを指定するのにImageFormatクラスを使用するので、下記のように名前空間を記述する。
using namespace System::Drawing::Imaging;

 複数のメソッドから呼び出される定数、変数を下記のようにグローバルに宣言しておく。
static int WIDTH=320,HEIGHT=240;
static bool flag_src=false;
static bool flag_edge=false;
Bitmap ^bmap_src,^bmap_prewitt,^bmap_sobel,^bmap_laplace;
int selection;
static Rectangle rectangle0=Rectangle( 10, 35,WIDTH,HEIGHT);   //原画像srcの枠
static Rectangle rectangle1=Rectangle(340, 35,WIDTH,HEIGHT);   //画像prewittの枠
static Rectangle rectangle2=Rectangle( 10,285,WIDTH,HEIGHT);   //画像sobelの枠
static Rectangle rectangle3=Rectangle(340,285,WIDTH,HEIGHT);   //画像laplaceの枠


private: System::Void Form1_Load(System::Object^ sender, System::EventArgs^ e) {

   bmap_src=gcnew Bitmap(WIDTH,HEIGHT);
   bmap_prewitt=gcnew Bitmap(WIDTH,HEIGHT);
   bmap_sobel=gcnew Bitmap(WIDTH,HEIGHT);
   bmap_laplace=gcnew Bitmap(WIDTH,HEIGHT);

   //初期化しておかないと保存時に周辺に黒い枠ができる
   for(int i=0;i<WIDTH;i++)
      for(int j=0;j<HEIGHT;j++){
         bmap_prewitt->SetPixel(i,j,Color::White);
         bmap_sobel->SetPixel(i,j,Color::White);
         bmap_laplace->SetPixel(i,j,Color::White);
      }

}

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

   Graphics^ g=e->Graphics;

   int i,j,p,q,d,max_d,threshold;

   //Prewittカーネル
   array<int,2>^ prewitt_y={{1,0,-1},{1,0,-1},{1,0,-1}};
   array<int,2>^ prewitt_x={{1,1,1},{0,0,0},{-1,-1,-1}};

   //Sobelカーネル
   array<int,2>^ sobel_y={{1,0,-1},{2,0,-2},{1,0,-1}};
   array<int,2>^ sobel_x={{1,2,1},{0,0,0},{-1,-2,-1}};

   //Laplacianカーネル
   array<int,2>^ laplace={{0,-1,0},{-1,4,-1},{0,-1,0}};

   array<Color,2>^ color_data=gcnew array<Color,2>(WIDTH,HEIGHT);
   array<int,2>^ density=gcnew array<int,2>(WIDTH,HEIGHT);

   //エッジ検出を実行する
   if(flag_src){

      //原画像のピクセルデータを作成する
      for(i=0;i<WIDTH;i++)
         for(j=0;j<HEIGHT;j++)
            color_data[i,j]=bmap_src->GetPixel(i,j);

      //Prewittカーネル
      max_d=0;
      for(i=1;i<WIDTH-1;i++)
         for(j=1;j<HEIGHT-1;j++){
            p=makeConvolution(i,j,color_data,prewitt_x);
            q=makeConvolution(i,j,color_data,prewitt_y);
            density[i,j]=Math::Max(p,q);
            if(density[i,j]>max_d) max_d=density[i,j];
         }
      threshold=max_d/8;
      for(i=1;i<WIDTH-1;i++)
         for(j=1;j<HEIGHT-1;j++){
            if(density[i,j]>threshold) d=0;
            else d=255;
            bmap_prewitt->SetPixel(i,j,Color::FromArgb(d,d,d));
         }

      //Sobelカーネル
      max_d=0;
      for(i=1;i<WIDTH-1;i++)
         for(j=1;j<HEIGHT-1;j++){
            p=makeConvolution(i,j,color_data,sobel_x);
            q=makeConvolution(i,j,color_data,sobel_y);
            density[i,j]=Math::Max(p,q);
            if(density[i,j]>max_d) max_d=density[i,j];
         }
      threshold=max_d/8;
      for(i=1;i<WIDTH-1;i++)
         for(j=1;j<HEIGHT-1;j++){
            if(density[i,j]>threshold) d=0;
            else d=255;
            bmap_sobel->SetPixel(i,j,Color::FromArgb(d,d,d));
         }

      //Laplacianカーネル
      max_d=0;
      for(i=1;i<WIDTH-1;i++)
         for(j=1;j<HEIGHT-1;j++){
            density[i,j]=makeConvolution(i,j,color_data,laplace);
            if(density[i,j]>max_d) max_d=density[i,j];
         }
      threshold=max_d/8;
      for(i=1;i<WIDTH-1;i++)
         for(j=1;j<HEIGHT-1;j++){
            if(density[i,j]>threshold) d=0;
            else d=255;
            bmap_laplace->SetPixel(i,j,Color::FromArgb(d,d,d));
         }

      flag_src=false;
      flag_edge=true;
   }

   //各画像を描画する
   if(flag_edge){
      g->DrawImage(bmap_src,rectangle0);
      g->DrawImage(bmap_prewitt,rectangle1);
      g->DrawImage(bmap_sobel,rectangle2);
      g->DrawImage(bmap_laplace,rectangle3);
   }

}

private: System::Void menuOpenFile_Click(System::Object^ sender, System::EventArgs^ e) {

   openFileDialog1->Filter="JPGファイル(*.jpg)|*.jpg|PNGファイル(*.png)|*.png|GIFファイル(*.gif)|*.gif|BMPファイル(*.bmp)|*.bmp";
   if(openFileDialog1->ShowDialog()==System::Windows::Forms::DialogResult::OK){
      bmap_src=gcnew Bitmap(openFileDialog1->FileName);
      flag_src=true;
      this->Refresh();
   }

}

private: System::Void menuSaveFile_Click(System::Object^ sender, System::EventArgs^ e) {

   Bitmap^ bmap=gcnew Bitmap(WIDTH,HEIGHT);
   ImageFormat^ format1;

   switch(selection){
      case 1:bmap=bmap_prewitt;break;  //画像prewittを保存する
      case 2:bmap=bmap_sobel;break;   //画像sobelを保存する
      case 3:bmap=bmap_laplace;      //画像laplaceを保存する
   }

   saveFileDialog1->Filter="JPGファイル(*.jpg)|*.jpg|PNGファイル(*.png)|*.png|GIFファイル(*.gif)|*.gif|BMPファイル(*.bmp)|*.bmp";
   if(saveFileDialog1->ShowDialog()==System::Windows::Forms::DialogResult::OK){
      switch(saveFileDialog1->FilterIndex){
         case 1:format1=ImageFormat::Jpeg;break;
         case 2:format1=ImageFormat::Png;break;
         case 3:format1=ImageFormat::Gif;break;
         case 4:format1=ImageFormat::Bmp;
      }
      bmap->Save(saveFileDialog1->FileName,format1);
   }

   this->Refresh();

}

private: int makeConvolution(int x, int y, array<Color,2>^ color,array<int,2>^ kernel){

   int sum_r=0,sum_g=0,sum_b=0;

   //コンボリューションの実行
   for(int j=-1;j<=1;j++)
      for(int i=-1;i<=1;i++){
         sum_r+=color[x+i,y+j].R*kernel[i+1,j+1];
         sum_g+=color[x+i,y+j].G*kernel[i+1,j+1];
         sum_b+=color[x+i,y+j].B*kernel[i+1,j+1];
      }

   return (Math::Abs(sum_r)+Math::Abs(sum_g)+Math::Abs(sum_b))/3; //RGBの絶対値の平均値を返す

}

private: System::Void Form1_MouseDown(System::Object^ sender, System::Windows::Forms::MouseEventArgs^ e) {

   Graphics^ g=this->CreateGraphics();

   Point point1=e->Location;    //マウスの座標を取得

   if(rectangle1.Contains(point1)) selection=1;     //画像prewittを選択
   else if(rectangle2.Contains(point1)) selection=2;  //画像sobelを選択
   else if(rectangle3.Contains(point1)) selection=3;  //画像laplaceを選択
   else selection=0;

   //赤枠を全部消す(背景色にする)
   Pen^ pen1=gcnew Pen(this->BackColor);
   g->DrawRectangle(pen1,rectangle1);
   g->DrawRectangle(pen1,rectangle2);
   g->DrawRectangle(pen1,rectangle3);

   //選択された画像の周辺を赤枠で囲む
   switch(selection){
      case 1:g->DrawRectangle(Pens::Red,rectangle1);break;
      case 2:g->DrawRectangle(Pens::Red,rectangle2);break;
      case 3:g->DrawRectangle(Pens::Red,rectangle3);
   }

}

得られた結果
 図2で、左上は、「ファイル」→「開く」でファイルから読み込んだ原画像、右上はPrewitt、左下はSobel、右下はLaplacianによるエッジ検出後の画像である。PrewittとSobelの差はほとんど認められない。 Laplacianは、やや不鮮明である。左下のSobelが赤い枠で囲まれているのは、ファイルへ保存する準備として、選択されていることを示す。


図2 得られた画面