2011. 1.24.
石立 喬

Visual C++ 2010 Express を用いた易しい画像処理(9)

―――モノクロ化と二値化(グローバルしきい値法、ローカルしきい値法)―――


 画像処理の中で、モノクロ化は原画像のピクセルの色情報を単独で処理するものの一つであるが、それ自身の応用は、あまりなく、画像から物体や文字を抽出するための二値化の前処理として用いられることが多い。二値化も、ピクセルの単独処理の一つであるが、二値化に使用するしきい値を、周辺のピクセル値を参照して決定するローカルしきい値法を用いると良い結果が得られる。ここでは、ローカルしきい値法で著名なSauvolaの方法を、一部変更して使用する。

モノクロ(白黒)画像とカラー画像のモノクロ化
 モノクローム(monochrome、単色)画像を略したもので、白黒画像ともグレイスケール(grayscale)画像とも言う。画像を白から黒までの明暗だけで表現し、R、G、Bに分解すると、それらの値は同一である。
 NTSC(National Television System Committee)方式のテレビでは輝度をYとして、
    Y = 0.299R + 0.587G + 0.114B
を用いているが、厳密な上記の式を使う必要もなく、例えば、
    Y = (2*R + 4*G + B)/7
程度でも十分である。

二値画像とモノクロ画像の二値化
 二値(binary)画像とは、ピクセル濃度が、255(白)と0(黒)のみで表される画像で、二値化(binarization)には、しきい値(threshold)を使用する。たとえば、しきい値を127とすると、これより大きい濃度(200など)のピクセルの濃度は強制的に255に、これより小さい濃度のピクセルは0にする。二値化の手法は、このしきい値をどのようにして決めるかの違いであり、大別して下記がある。
◎グローバルしきい値(global threshold、静的しきい値とも言う)法
 画像全体のヒストグラムを採り、その結果から、画面全体に共通のしきい値を決める。
 ・ミッドレンジ(平均値)--- (最大濃度値+最小濃度値)/2
 ・メディアン(中央値) ---- ヒストグラムで累積値が50%になる濃度
 ・大津の方法 --- 判別分析法とも言われ、処理が複雑の割には、ローカルしきい値に勝るものではない
◎ローカルしきい値(local threshold、動的しきい値、適応型しきい値とも言う)法
 二値化したい対象ピクセルの周辺の平均ピクセル濃度や標準偏差を求め、それに関連付けてしきい値を決める。代表的なものにSauvolaの方法がある。

ローカルしきい値法の詳細説明
◎Sauvolaの方法と、その改良
 Sauvolaの方法(Efficient Implementation of Local Adaptive Thresholding Thchniques Using Integral Images)では、しきい値thresholdを下式のようにして求める。meanは、対象とするピクセルを中心とする一定の幅のウインドウ内のピクセル濃度の平均、mean2は二乗平均である。deviationは標準偏差standard deviationである。kは係数で、標準偏差の効果をどの程度加味するかを決める。k=0の場合は、平均値がそのまましきい値となる。deviationの分母にある128は、0〜255の範囲でのdeviationの最大値128で正規化するためである。
    

 Sauvolaの方法の改良は、平均や二乗平均を計算するに当たって、注目するピクセルの周辺のピクセルを、その都度読み取って計算することなく、integral imageと言う積分形表示のデータから計算する。これは、Robust Real-Time Face Detectionに紹介されている。
 実際に試してみた結果、このSauvolaの方法は、二乗平均mean2を求めるのが面倒である割には、結果が、それほど芳しくなかった。したがって、ここでは、標準偏差standard deviationを使用しないで、meanのみからしきい値thresholdを求める独自の方法を選んだ。
◎画像の積分形表示(integral image)と、その使い方
 画像上の任意の指定した範囲のピクセルの濃度を調べ、それを合計する処理は、回数が多くなると、時間がかかる。これを簡単にしたのが、上記のViolaとJonesの方法である。
 画像上の点P(x,y)のピクセル値をD(x,y)とすると、積分形表示II(x,y)は、次のようにして表される。
        

 つまり、 II(x,y)は、原点と点(i,j)を結ぶ直線を対角線とする四辺形の領域内のピクセルの濃度を合計したものである。これは、画面の一番上の行とそれ以降の行とに分けて計算すると、容易に計算できる。
 図1に示す通り、積分形表示を用いると、任意の四角形内の濃度の合計を簡単に求めることができる。後述するプログラムでは、A点は(x-h,y-h)に、B点は(x+h,y-h)に、C点は(x-h,y+h)に、D点は(x+h,y+h)に相当する。


図1 積分形表示データから、任意の四辺形の濃度の合計を求める


◎注目するピクセル(x,y)を中心にして、縦横がwの正方形のウインドウを考え、その範囲内のピクセルの平均濃度meanを求める。
 ウインドウの幅wの半分w/2をhとし、先に述べた積分形表示(integral image)を用いると、四個のピクセル位置で囲まれる正方形のウインドウに含まれるピクセル濃度の合計sumは、
  sum=integral(x+h,y+h)+integral(x-h,y-h)-integral(x+h,y-h)-integral(x-h,y+h)
で簡単に計算できる。したがって、ウインドウ内のピクセルの平均濃度は、
  mean=sum/(w*w)である。
 ただし、周辺部でx-hやx+hが画像の範囲をはみ出してしまうので、考慮が必要になる。

◎ウインドウ範囲内の濃度平均meanを、ローカルしきい値thresholdに反映させる方法(筆者の案)
 濃度平均meanを、それをそのまま、しきい値とすると、明るいところも暗いところも、白黒の比率が同一になり、全体の画面として適切でない。そこで、絶対的な明るさに対して、相対的な明るさ(その付近の平均値meanに対する)を加味する方法を採用する。
 すなわち、図2に示すように、寄与度kを用いて、濃度平均の影響を緩和する。k=0にすると、求めた濃度平均にかかわらず、thresholdは127に固定される。これは、もはやローカルしきい値法ではない。


図2 濃度平均から、変換式でしきい値を求める


グローバルしきい値法のプログラム
 主要部は下記の通りである。
1)原画像をスキャンして読み取り、r、g、bに分解してから、濃度d=(r*2+g*4+b)/7で、グレイ画像の濃度に変換し、グレイ画像を生成する。
2)グレイ画像をスキャンして読み取り、r成分(r、g、bすべて同一なので、rで代表させる)のみを取り出し、濃度別のヒストグラムを採り、全ピクセル数のsize=WIDTH*HEIGHTに対して、その5%、50%、95%に当たる濃度level_05、level_50、level_95を求め、これらを用いて、二種類のしきい値を計算する。
3)グレイ画像を、与えられたしきい値thresholdと比較して、二値化画像を生成する。

 画像の寸法は、複数のメソッドで共用されるので、グローバル化しておく。
  static int WIDTH=320,HEIGHT=240;

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

  Graphics^ gr=e->Graphics;

  int X0=10,Y0=10;
  int X1=340,Y1=260;

  Bitmap^ bmap_gray=gcnew Bitmap(WIDTH,HEIGHT);    //グレイ画像
  Bitmap^ bmap_bin=gcnew Bitmap(WIDTH,HEIGHT);    //二値化画像

  array<int>^ threshold=gcnew array<int>(2);     //しきい値(二種類あるので)

  //原画像を読み込む
  Bitmap^ bmap_src=gcnew Bitmap("sample.PNG");
  //原画像を左上に表示する
  gr->DrawImage(bmap_src,X0,Y0);
  //原画像をグレイ画像化する
  bmap_gray=changeToGray(bmap_src);
  //グレイ画像を右上に表示する
  gr->DrawImage(bmap_gray,X1,Y0);
  //しきい値を求める
  getThreshold(bmap_gray,threshold);
  //グレイ画像をthreshold[0]で二値画像化する
  bmap_bin=changeToBinary(bmap_gray,threshold[0]);
  //二値化画像を左下に表示する
  gr->DrawImage(bmap_bin,X0,Y1);
  //グレイ画像をthreshold[1]で二値画像化する
  bmap_bin=changeToBinary(bmap_gray,threshold[1]);
  //二値化画像を右下に表示する
  gr->DrawImage(bmap_bin,X1,Y1);

}

//原画像をグレイ画像化する
private: Bitmap^ changeToGray(Bitmap^ bmap_src){

  Bitmap^ bmap_dst=gcnew Bitmap(WIDTH,HEIGHT);

  int r,g,b,d;
  Color color1;

  for(int j=0;j<HEIGHT;j++)
    for(int i=0;i<WIDTH;i++){
      color1=bmap_src->GetPixel(i,j);
      r=color1.R;
      g=color1.G;
      b=color1.B;
      d=(r*2+g*4+b)/7;
      bmap_dst->SetPixel(i,j,Color::FromArgb(d,d,d));
    }

  return bmap_dst;

}


//画像から、最適なしきい値を求める
private:void getThreshold(Bitmap^ bmap,array<int>^ threshold){

  int i,j,r;
  int size=WIDTH*HEIGHT;

  array<int>^ hist=gcnew array<int>(256);
  Color color1;
  int sum;
  int count_05=size/20;
  int count_50=size/2;
  int count_95=size-count_05;
  int level_05,level_50,level_95;

  //画像のヒストグラムを採る
  for(i=0;i<256;i++)
    hist[i]=0;
  for(j=0;j<HEIGHT;j++)
    for(i=0;i<WIDTH;i++){
      color1=bmap->GetPixel(i,j);
      r=color1.R;
      hist[r]++;
    }

  //ヒストグラムのI5%、50%、95%に相当する濃度を求める
  sum=0;
  i=0;
  do{
    sum+=hist[i++];
  }while(sum<count_05);
  level_05=i-1;
  do{
    sum+=hist[i++];
  }while(sum<count_50);
  level_50=i-1;
  do{
    sum+=hist[i++];
  }while(sum<count_95);
  level_95=i-1;

  //しきい値を求める
  threshold[0]=(level_05+level_95)/2;  //ミッドレンジ(平均値)
  threshold[1]=level_50;         //メディアン(中央値)

}

//グレイ画像をthresholdで二値画像化する
private: Bitmap^ changeToBinary(Bitmap^ bmap_src,int threshold){

  Bitmap^ bmap_dst=gcnew Bitmap(WIDTH,HEIGHT);

  int r,d;
  Color color1;

  for(int j=0;j<HEIGHT;j++)
    for(int i=0;i<WIDTH;i++){
      color1=bmap_src->GetPixel(i,j);
      r=color1.R;
      if(r>threshold)   d=255;
      else         d=0;
      bmap_dst->SetPixel(i,j,Color::FromArgb(d,d,d));
    }

  return bmap_dst;

}

グローバルしきい値法の実行結果
 図3は、上記プログラムの実行結果で、左上は原画像、右上はグレイ化した画像、左下はミッドレンジ(平均値)をしきい値として二値化した画像、右下はメディアン(中央値)をしきい値として二値化した画像である。ここで使用した原画像の場合は、メディアン(中央値)の方が良い結果が得られている。


図3 グローバルしきい値法による二値化の実行画面


ローカルしきい値法のプログラム
 主要部は下記の通りである。
1)グレイ画像を生成する(これは、グローバルの場合と同じなので、プログラムは省略)。
2)グレイ画像をスキャンして読み取り、r成分から配列データpixel_dataを得る。
3)配列データpixel_dataから積分形二次元配列integralを求める。一番上の列は、容易に求まる。二番目以降の列は、それまでの合計結果を利用して、それに加算する方法で求める。
4)まず最初、周辺処理を行い、ウインドウの範囲が元の画像の範囲外になる場合は、中心となるピクセル位置を内側にずらる。これは、単に場所を借りるだけで、しきい値が設定されるのは、本来の元のピクセル位置である。ウインドウ内のピクセル濃度の合計は、すでに述べた式により求め、平均はウインドウの面積で割って求める。得られた平均濃度に対し、寄与率kを加味して、しきい値thresholdを求める。
5)グレイ画像をスキャンして読み取り、r成分としきい値配列thresholdと比較して、二値化画像を生成する。

 画像の寸法は、複数のメソッドで共用されるので、グローバル化しておく。
  static int WIDTH=320,HEIGHT=240;


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

  Graphics^ gr=e->Graphics;

  int X0=10,Y0=10;
  int X1=340,Y1=260;

  Bitmap^ bmap_gray=gcnew Bitmap(WIDTH,HEIGHT);    //グレイ画像
  Bitmap^ bmap_bin=gcnew Bitmap(WIDTH,HEIGHT);    //二値化画像
  Bitmap^ bmap_thres=gcnew Bitmap(WIDTH,HEIGHT);   //変動しきい値画像

  array<int,2>^ threshold=gcnew array<int,2>(WIDTH,HEIGHT);
  array<int,2>^ pixel_data=gcnew array<int,2>(WIDTH,HEIGHT);
  array<int,2>^ integral=gcnew array<int,2>(WIDTH,HEIGHT);
  int window_size=32;     //ウインドウの幅
  float k=0.75f;         //濃度平均の寄与率

  //原画像を読み込む
  Bitmap^ bmap_src=gcnew Bitmap("sample.PNG");
  //原画像を左上に表示する
  gr->DrawImage(bmap_src,X0,Y0);
  //原画像をグレイ画像化する
  bmap_gray=changeToGray(bmap_src);
  //グレイ画像を右上に表示する
  gr->DrawImage(bmap_gray,X1,Y0);
  //グレイ画像をピクセルデータ化する
  pixel_data=getPixelDataFromBitmap(bmap_gray);
  //ピクセルデータから積分形データを求める
  integral=createIntegralImageFromPixelData(pixel_data);
  //ウインドウ幅window_sizeと寄与率kを与えて、integralから、しきい値を求める
  threshold=getThresholdFromIntegral(integral,window_size,k);
  //しきい値を画像にする
  bmap_thres=createImageFromThreshold(threshold);
  //しきい値画像を左下に表示する
  gr->DrawImage(bmap_thres,X0,Y1);
  //しきい値で二値画像を生成する
  bmap_bin=changeToAdaptiveBinary(bmap_gray,threshold);
  //二値化画像を右下に表示する
  gr->DrawImage(bmap_bin,X1,Y1);

}


//グレイ画像をピクセルデータ化する
private: array<int,2>^ getPixelDataFromBitmap(Bitmap^ bmap){

  Color color1;
  array<int,2>^ p_data=gcnew array<int,2>(WIDTH,HEIGHT);

  for(int j=0;j<HEIGHT;j++)
    for(int i=0;i<WIDTH;i++){
      color1=bmap->GetPixel(i,j);
      p_data[i,j]=color1.R;
    }
  return p_data;

}

//ピクセルデータから積分形二次元配列データを求める
private: array<int,2>^ createIntegralImageFromPixelData(array<int,2>^ p_data){

  array<int,2>^ integral=gcnew array<int,2>(WIDTH,HEIGHT);

  int sum=0;

  //一番上の行
  for(int i=1;i<WIDTH;i++){
    sum+=p_data[i,0];
    integral[i,0]=sum;
  }
  //二番目以降の行
  for(int j=1;j<HEIGHT;j++){
    sum=0;
    for(int i=0;i<WIDTH;i++){
      sum+=p_data[i,j];
      integral[i,j]=integral[i,j-1]+sum;
    }
  }

}

//積分形二次元配列integralから、ウインドウ幅w_sizeと寄与率kを与えて、しきい値を求める
private: array<int,2>^ getThresholdFromIntegral(array<int,2>^ integral,int w_size,float k){

  int mean;
  int i,j,ii,jj;
  int h=w_size/2;
  int ww=w_size*w_size;
  array<int,2>^ threshold=gcnew array<int,2>(WIDTH,HEIGHT);

  for(j=0;j<HEIGHT;j++)
    for(i=0;i<WIDTH;i++){
      //周辺処理
      if(i<h) ii=h;
      else if(i>=WIDTH-h) ii=WIDTH-h-1;
      else     ii=i;
      if(j<h) jj=h;
      else if(j>=HEIGHT-h) jj=HEIGHT-h-1;
      else    jj=j;
      //integralを用いて、±hの範囲の平均meanを得る
      mean=(integral[ii+h,jj+h]+integral[ii-h,jj-h]-integral[ii+h,jj-h]-integral[ii-h,jj+h])/ww;
      //寄与率kを用いて、meanからthresholdを生成する
      threshold[i,j]=127+(int)(k*(mean-127));
    }
  return threshold;

}

//ピクセルデータ二次元配列p_dataを画像にする
private: Bitmap^ createImageFromThreshold(array<int,2>^ p_data){

  int i,j,d;
  Bitmap^ bmap=gcnew Bitmap(WIDTH,HEIGHT);

  for(j=0;j<HEIGHT;j++)
    for(i=0;i<WIDTH;i++){
      d=p_data[i,j];
      bmap->SetPixel(i,j,Color::FromArgb(d,d,d));
    }
  return bmap;

}

//しきい値二次元配列thresoldで二値画像を生成する
private: Bitmap^ changeToAdaptiveBinary(Bitmap^ bmap_src,array<int,2>^ threshold){

  Bitmap^ bmap_dst=gcnew Bitmap(WIDTH,HEIGHT);
  Color color1;
  int r,d;

  for(int j=0;j<HEIGHT;j++)
    for(int i=0;i<WIDTH;i++){
      color1=bmap_src->GetPixel(i,j);
      r=color1.R;
      if(r>threshold[i,j]) d=255;
      else d=0;
      bmap_dst->SetPixel(i,j,Color::FromArgb(d,d,d));
    }
  return bmap_dst;

}


ローカルしきい値法の実行画面
 図4は、上記プログラムの実行結果の下半分で、左は、ローカルしきい値法によって求めたしきい値を画像化して表示したもの、右はローカルしきい値法によって二値化した画像である。ウインドウの幅を32ピクセルに選び、計算で得られた平均濃度meanに対して寄与率0.75を選んでしきい値を決定してある(これらの条件が最も良かった)。


図4 ローカルしきい値法による二値化の実行画面(下半分のみ)


ウインドウ幅wの最適化と、平均濃度meanの寄与度の最適化
 ローカルしきい値法による二値化の結果は、ウインドウ幅wの選び方、平均濃度meanの寄与度の選び方によって、結果が大きく異なることが分かった。そこで、これらを変化して、結果を調べてみた。
◎平均濃度meanの寄与度を100%に固定して、ウインドウ幅wを変えてみた結果を図5に示す。ウインドウ幅8ピクセルは、細かすぎてノイズが出過ぎ、64ピクセルでは、折角のローカル性が、やや失われている。このサイズの画像の場合には、32ピクセル程度が最適と思われる(幅の1/10に相当)。


8ピクセル


16ピクセル


32ピクセル

64ピクセル

図5 ウインドウ幅wを変えた結果


◎ウインドウ幅wを32に固定して、平均濃度meanの寄与度kを変えてみた結果を図6に示す(寄与度100%の場合は上図にあるので省略)。寄与度75%が最適ではないかと思われる。


75%

50%

図6 平均濃度meanの寄与度kを変えた結果 


「Visual C++ の勉強部屋」(目次)へ