SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

特集記事

画像を同じ特徴を持つ複数の領域に分ける方法

分割・統合(Split & Merge)法による画像の領域分割


  • X ポスト
  • このエントリーをはてなブックマークに追加

プログラム

 Win32APIを用いてプログラムを作成します。分割・統合法による処理はSplitMergeクラスにまとめました。以下では要点を解説します。なお、SplitMergeクラスは、サンプルファイルの「SplitAndMerge.cpp」と「SplitAndMerge.h」で定義しています。

データ構造

 部分領域をどのように表現するのか、というのは大きなポイントです。最も処理効率が良いのは、頂点座標だけを保存する方法でしょう。しかし、この方法では「分割は簡単だが統合は困難」、という問題があります。仮に解決できたとしても、ある座標はどの部分領域に属しているのか、という基本的な判定さえ困難です。

 これでは使い勝手が悪いですよね。そこで今回は、ラベリングによって各部分領域を区別することにします。ラベリングは、仕組みは簡単ですが、時間のかかる処理なので、高速化に注力する必要があります。

頂点座標を利用する場合の問題点
 分割で作成される部分領域は四角形ですが、統合で作成される部分領域は多角形になります。従って、可変長のデータ構造が必要です。また、頂点座標は左回り、または右回りに順番に並んでいる必要があるので、統合される二つの部分領域の頂点座標の配列を再構成する必要があります。このアルゴリズムは難解です。
ラベリング
 「ラベリング」とは、領域全体に唯一無二の番号を付与することです。画像そのものを書き換えるのではなく、ラベリング用のメモリ領域を確保して、対応する場所に番号を付与します。

SplitMergeクラスの初期設定

 SplitMergeクラスは、以下の手順でメンバ関数を呼び出します。

  1. 画像の入力
  2. ……SetImageメンバ関数
  3. 分割、統合、微小領域統合の閾値設定
  4. ……SetThresholdメンバ関数
  5. 初期領域の設定、処理開始
  6. ……SplitAndMergeメンバ関数

画像の入力

 処理対象の画像には、DIB(デバイス独立ビットマップ)を入力して下さい。DIBの原点座標は左下ですが、表示する時はデバイスコンテキストに描画するので、原点座標が左上になるように変換しています。ここではなく、描画する時に変換する方法もあります。changeChannelメンバ関数は、実際の処理で使用する表色系(色を数値的に表すための体系)に変換した画像を作成します。今回は濃度画像を作成することになります。

// 処理したい画像を入力する
void SplitMerge::SetImage(const BYTE *c_pImage,
    DWORD width,DWORD height,BYTE byte)
{
    if(c_pImage==NULL || width==0 || height==0) return; // error
    // 24ビットまたは32ビットだけ受理する
    if(byte!=3 && byte!=4) return;

    m_w=width; m_h=height; m_byte=byte;
    // m_len は一行のバイト数
    m_len=(width*byte%4) ? width*byte+(4-width*byte%4) : width*byte;

    // 入力画像をコピーする
    if(m_pImage) free(m_pImage);
    m_pImage=(BYTE*)malloc(m_len*height);
    for(DWORD y=0;y<height;y++)
    {
      // 左上を原点に
      memcpy(&m_pImage[y*m_len],&c_pImage[(height-1-y)*m_len],m_len);
    }

    // 実際の処理で使用する表色系に変換した画像を作成する
    changeChannel();

    // ラベリング用のメモリ領域を確保する
    if(m_pLabel) free(m_pLabel);
    m_pLabel=(DWORD*)malloc(sizeof(DWORD)*width*height);
    memset(m_pLabel,0,sizeof(DWORD)*width*height);
}

 m_pChannelが濃度画像です。入力画像m_pImageはそのまま保存しておき、以降の処理はm_pChannelに対して行われます。濃度以外の画像を作成する場合もchangeChannelメンバ関数に記述して下さい。また、処理対象の画像は一つだけ、という制約はありません。複数の処理画像を作成したい場合はm_pChannel2m_pChannel3などを追加して下さい。

// 実際の処理で使用する表色系に変換した画像を作成する
void SplitMerge::changeChannel(void)
{
    if(m_pImage==NULL) return;       // error

    if(m_pChannel) free(m_pChannel);
    m_pChannel=(BYTE*)malloc(m_w*m_h);

    BYTE r,g,b;
    DWORD x,y;
    for(y=0;y<m_h;y++){
        for(x=0;x<m_w;x++){
            r=m_pImage[x*m_byte+2+y*m_len];
            g=m_pImage[x*m_byte+1+y*m_len];
            b=m_pImage[x*m_byte +y*m_len];
            // 濃度に変換
            m_pChannel[x+y*m_w]=(BYTE)((r+g+b)/3.0+0.5);
        }
    }
}

分割、統合、微小領域統合の閾値設定

 SetThresholdメンバ関数では、各処理の判断基準となる境界値(閾値)の設定を行います。

閾値(しきいち)の設定
引数用途
smoothHistogram分割の時に作成するヒストグラムを平滑化する回数です。
split分割の閾値です。濃度ヒストグラムの山の面積がsplitパーセントより小さければ分割します。
merge統合の閾値です。隣接する濃度平均値の差がmergeより小さければ統合します。
minSize微小領域統合の閾値です。ある領域の面積がminSizeより小さければ統合します。
// 閾値を設定する
void SplitMerge::SetThreshold(BYTE smoothHistogram,
    double split,double merge,DWORD minSize)
{
    if(split<=0 || split>1) return;     // error
    if(merge<=0 || merge>255) return;   // error

    m_smooth=smoothHistogram; // ヒストグラムを平滑化する回数
    m_split=split;     // 山の面積の割合
    m_merge=merge;     // 濃度平均値の差
    m_minSize=minSize; // 領域の最小面積
}

初期領域の設定、処理開始

 引数で実際に処理する領域(初期領域)の座標を設定します。この領域外は処理対象外となります。通常は、画像四隅の座標を設定しますが、一部の範囲だけを処理したい場合は、その座標を設定して下さい。不要な領域を除外することで、処理速度が向上します。

 最下部のCreateThread関数に注目して下さい。実際の処理はサブスレッドで行います。スレッド方式を採用した理由は、領域分割の処理は大変時間がかかるため、ビジー状態によってユーザーに不安を与えてしまう可能性があるからです。そのためスレッド化することで、全体の処理時間は若干増加しますが、処理の途中経過を表示することで体感時間は短くしています。

 ただし、DOSプログラムから呼び出す場合は、画像を表示することが無いので、スレッド化する意味がありません。むしろ逆効果です。スレッド化を解除する場合は、スレッドプロシージャであるsmThreadProcメンバ関数のプログラムをCreateThread関数の位置に記述して下さい。TerminateThread関数はスレッドを強制終了させる関数です。あまり実行して欲しくないようなことがヘルプに記述されていますが、他に良い方法がないので使っています。私の環境では正常に動作しました。

// 分割・統合
void SplitMerge::SplitAndMerge(DWORD left,
    DWORD top,DWORD right,DWORD bottom)
{
    if(m_pChannel==NULL || m_pLabel==NULL) return; // error
    if(left>right || top>bottom) return;      // error
    if(right>=m_w || bottom>=m_h) return;     // error
    if(m_split==0 || m_merge==0) return;      // error

    m_l=left; m_t=top; m_r=right; m_b=bottom; // 処理する領域

    if(m_hThread)
    {
        TerminateThread(m_hThread,0);    // スレッド強制終了
        DWORD code;
        GetExitCodeThread(m_hThread,&code);
        while(code==STILL_ACTIVE)        // スレッド終了まで待機
        {
            Sleep(1);
            GetExitCodeThread(m_hThread,&code);
        }
        CloseHandle(m_hThread);
    }
    DWORD dwThreadID;
    m_hThread=CreateThread(NULL,0,smThreadProc,this,0,&dwThreadID);
}

スレッドプロシージャ

 smThreadProcSplitMergeクラスのメンバ関数です。メンバ関数をスレッドプロシージャとして用いる場合は、staticメンバ関数にしなければなりません。staticメンバ関数にするためには、宣言にstaticを付けます。定義には付けられません。staticメンバ関数は普通の関数と同じように振る舞います。また、非staticメンバには通常アクセスできませんが、クラスオブジェクトのポインタをsmThreadProcメンバ関数の引数に渡すことで、このポインタからクラスオブジェクトの内部にアクセスすることが出来ます。非公開メンバにもアクセスできるのが、普通の関数にクラスオブジェクトのポインタを渡した時との違いです。

class SplitMerge
{
private:
    ……
    static DWORD WINAPI smThreadProc(void *lpParameter);
    ……
};
// 分割・統合スレッド
DWORD WINAPI SplitMerge::smThreadProc(void *lpParameter)
{
    SplitMerge *p=(SplitMerge*)lpParameter;

    p->m_complete=false;

    // 分割
    p->m_number=1;     // 処理済みの領域には 0 以外の番号を付ける
    p->split(p->m_l,p->m_t,p->m_r,p->m_b);

    // 統合準備
    p->prepare();

    // 統合
    DWORD n;
    do{
        n=p->merge();
    }while(n);

    // 微小領域の統合
    p->absorb();

    p->m_complete=true;

    return 0;
}
マルチスレッドの注意
 失敗談を一つ。スレッドプロシージャの中でメモリ領域の解放と確保を行う処理を記述してしまった、という失敗です。これは、メモリ領域のサイズを変更するための処理でした。ところが、そのメモリ領域は他のスレッドからも参照されていたので、運悪く解放だけしてスレッドが切り替わった場合に、メモリ参照エラーが起こってしまった、というわけです。

分割

 分割は単純な再帰処理で実現できます。再帰は苦手な方も多いと思いますが、これは簡単なので大丈夫です。再帰を理解するポイントは終了処理を明確にすることです。今回の終了条件は、分割判定で否定された、です。分割の可否はifSplitメンバ関数で判定します。

 各部分領域にはラベリングで番号を付けますが、いつラベリングするかで処理効率が違ってきます。理論を素直に考えると、分割する毎にラベリングすることになりますが、それでは同じ領域に何度もラベリングし直すことになります。そこで、無駄がない方法として、再帰から脱出する時にラベリングします。ただし、再帰を一つ脱出する度に、既に分割され、ラベリングされた領域を参照することになるので、注意が必要です。ここで再びラベリングしたら、致命的な不具合を引き起こします。しかしこの問題は、m_pLabelの初期値が0であることを利用すれば、容易に回避できます。

// 分割
void SplitMerge::split(DWORD left,DWORD top,DWORD right,DWORD bottom)
{
    if(left>right || top>bottom) return;      // error
    if(right>=m_w || bottom>=m_h) return;     // error

    // 更に分割する?もうしない?
    if(ifSplit(left,top,right,bottom)==false)
    {
        if(m_pLabel[left+top*m_w]==0)      // まだラベリングしてない
        {
            labeling(left,top,right,bottom);
            m_n++;  // 分割数
        }
        return;     // 再帰脱出
    }
    DWORD x=(left+right)/2,y=(top+bottom)/2;
    split(left,top,x,y);         // 左上
    split(x+1,top,right,y);      // 右上
    split(left,y+1,x,bottom);    // 左下
    split(x+1,y+1,right,bottom); // 右下
}

 ifSplitメンバ関数は理論のままなので、ソースファイルを確認してください。ちなみに、現在、最も処理に時間がかかっているのは、この分割判定だと思われます。良いアイディアをお持ちの方は改良してみて下さい。

 labelingメンバ関数も単純なので、ソースファイルを確認してください。複雑な形状の領域をラベリングする時は工夫が必要ですが、分割する時は必ず四角形であり、しかも四隅の座標がわかっているという理想状態なので、特に考えるべきことはありません。

統合準備

 分割の次は統合ですが、その前に統合処理を高速化するための準備をしましょう。統合していくに従って、領域の形状は複雑になっていきます。もし何の工夫もしなかったらどうなるでしょうか。

 まず、領域の始点と終点がわかりません。このままでは、統合される領域をラベリングするために画像全体を走査したり、統合条件である平均濃度値をいちいち計算しなければなりません。これは大きな無駄です。

 これらの無駄を解消するために、以下のような値を保存します

  • 各部分領域の左上(始点)と右下(終点)の座標を保存する
  • 各部分領域の平均濃度値を保存する
  • 統合された新しい部分領域の平均濃度値を計算し直すために、各部分領域の面積を保存する

 ただし、今回の統合条件である平均濃度値のように、統合後の新しい平均濃度値を計算で求められるものばかりではありません。調べ直さなければならない特徴量もあります。

// 統合の時に使う値を計算
void SplitMerge::prepare(void)
{
    if(m_pAverage) free(m_pAverage); // 領域の平均濃度値
    if(m_pSize) free(m_pSize);       // 領域の面積
    if(m_pStartX) free(m_pStartX);   // 領域の左上 x 座標
    if(m_pStartY) free(m_pStartY);   // 領域の左上 y 座標
    if(m_pEndX) free(m_pEndX);       // 領域の右下の x 座標
    if(m_pEndY) free(m_pEndY);       // 領域の右下の y 座標

    // m_number は領域の個数
    m_pAverage=(double*)malloc(sizeof(double)*m_number);
    m_pSize=(DWORD*)malloc(sizeof(DWORD)*m_number);
    m_pStartX=(DWORD*)malloc(sizeof(DWORD)*m_number);
    m_pStartY=(DWORD*)malloc(sizeof(DWORD)*m_number);
    m_pEndX=(DWORD*)malloc(sizeof(DWORD)*m_number);
    m_pEndY=(DWORD*)malloc(sizeof(DWORD)*m_number);

    memset(m_pAverage,0,sizeof(double)*m_number);
    memset(m_pSize,0,sizeof(DWORD)*m_number);
    memset(m_pStartX,0,sizeof(DWORD)*m_number);
    memset(m_pStartY,0,sizeof(DWORD)*m_number);
    memset(m_pEndX,0,sizeof(DWORD)*m_number);
    memset(m_pEndY,0,sizeof(DWORD)*m_number);

    DWORD x,y,z;
    for(y=m_t;y<=m_b;y++){
        for(x=m_l;x<=m_r;x++){
            z=x+y*m_w;
            if(m_pSize[ m_pLabel[z] ]==0)
            {
              // 始点
              m_pStartX[ m_pLabel[z] ]=x; m_pStartY[ m_pLabel[z] ]=y;
            }
            // 終点
            m_pEndX[ m_pLabel[z] ]=x; m_pEndY[ m_pLabel[z] ]=y;
            // 合計濃度値をとりあえず求める
            m_pAverage[ m_pLabel[z] ]+=m_pChannel[z];
            m_pSize[ m_pLabel[z] ]++; // 面積
        }
    }
    for(DWORD i=1;i<m_number;i++){  // 0 は処理していない領域の番号
        if(m_pSize[i]==0) continue; // error
    m_pAverage[i]/=m_pSize[i];      // 平均濃度値
    }
}

統合

 統合処理は、隣接する部分領域を探すところから始まります。m_tm_bm_lm_rSplitAndMergeメンバ関数で設定した初期領域の座標です。この領域外は処理対象外です。隣接する方向としては、上下左右の四方向が考えられますが、左上から右下に走査する場合は、右と下だけ調べれば、全ての隣接する部分領域を調べられることになります。

 統合はラベリングし直すことに相当します。どちらの部分領域をラベリングし直せば良いかといえば、面積の小さい方をラベリングし直した方が、処理効率が良いでしょう。また、下記のプログラムでは、自領域の方がラベリングし直された可能性を考慮するため、右を調べた後に、自領域の番号m_pLabel[x+y*m_w]を再取得しています。

// 統合
DWORD SplitMerge::merge(void)
{
    DWORD c,r,b,n=0;
    DWORD x,y;
    for(y=m_t;y<=m_b;y++){
        for(x=m_l;x<=m_r;x++){
            c=m_pLabel[x+y*m_w];
            if(x+1<m_r)
            {
                r=m_pLabel[x+1+y*m_w];   // 右を調べる
                if(c!=r && ifMerge(c,r))
                {
                    labeling(c,r);
                    n++;
                }
            }
            c=m_pLabel[x+y*m_w];    // 統合されたかもしれないので
            if(y+1<m_b)
            {
                b=m_pLabel[x+(y+1)*m_w]; // 下を調べる
                if(c!=b && ifMerge(c,b))
                {
                    labeling(c,b);
                    n++;
                }
            }
        }
    }
    m_n-=n;
    return n; // 統合数
}

 統合判定を担当しているメンバ関数ifMergeは理論のままなので、ソースファイルを確認してください。labelingメンバ関数については次で解説しますが、これは分割の時に呼び出したlabelingメンバ関数とは別の関数です(多重定義)。

ラベリング

 下記のプログラムをご覧下さい。「#1」で面積の小さい領域がどちらであるかを調べています。「#2」で面積の小さい領域の番号を面積の大きい領域の番号で置き換えています。走査の始点と終点は、統合準備で調べておいた領域の左上と右下の座標です。ただし、その間の行は初期領域の左端から右端まで走査する必要があり、まだ無駄があります。「#3」では統合準備で調べた、平均濃度値、面積、領域の左上と右下の座標を、統合された新しい領域のそれに合うように計算し直しています。平均濃度値m_pAverage には誤差が含まれている(かもしれない)ので、計算し直すことで誤差が累積していきます。気になるのであれば、平均濃度値の代わりに合計濃度値を用いれば良いでしょう。二つの領域の左上と右下の座標の大小は式1から求められます。左上ほど小さな値になり、右下ほど大きな値になります。

式1
x座標 + y座標 * 画像の幅
ラベリングの処理
// 番号付け(統合する時に呼び出す)
void SplitMerge::labeling(DWORD number1,DWORD number2)
{
// ================================================== #1
    DWORD sml,lrg;
    if(m_pSize[number1]<m_pSize[number2])
    {
        // 面積の小さい領域と大きい領域の番号
        sml=number1; lrg=number2;
    }
    else
    {
        sml=number2; lrg=number1;
    }
// ================================================== #2
    DWORD x,y;
    DWORD xs,xe;
    for(y=m_pStartY[sml];y<=m_pEndY[sml];y++){
        if(y==m_pStartY[sml]) xs=m_pStartX[sml];
        else xs=m_l;      // 処理領域の左端
        if(y==m_pEndY[sml]) xe=m_pEndX[sml];
        else xe=m_r;      // 処理領域の右端
        for(x=xs;x<=xe;x++){
            if(m_pLabel[x+y*m_w]==sml){
                // 面積の小さい領域を置き換える
                m_pLabel[x+y*m_w]=lrg;
            }
        }
    }
// ================================================== #3
    double d=m_pAverage[sml]*m_pSize[sml]
        +m_pAverage[lrg]*m_pSize[lrg];
    DWORD u=m_pSize[sml]+m_pSize[lrg];
    m_pAverage[lrg]=d/u;      // 新しい領域の平均濃度値
    m_pSize[lrg]=u;     // 新しい領域の面積
    m_pAverage[sml]=0;       // 統合されたので無効な領域とする
    m_pSize[sml]=0;     // 統合されたので無効な領域とする
    if(m_pStartX[sml]+m_pStartY[sml]*m_w
        < m_pStartX[lrg]+m_pStartY[lrg]*m_w)
    {
        // 新しい領域の左上座標
        m_pStartX[lrg]=m_pStartX[sml];
        m_pStartY[lrg]=m_pStartY[sml];
    }
    // 統合されたので無効な領域とする
    m_pStartX[sml]=0;
    m_pStartY[sml]=0;
    if(m_pEndX[sml]+m_pEndY[sml]*m_w > m_pEndX[lrg]+m_pEndY[lrg]*m_w)
    {
        // 新しい領域の右下座標
        m_pEndX[lrg]=m_pEndX[sml];
        m_pEndY[lrg]=m_pEndY[sml];
    }
    // 統合されたので無効な領域とする
    m_pEndX[sml]=0;
    m_pEndY[sml]=0;
}

微小領域の統合

 微小領域の統合処理では、隣接している領域を見つけることが肝になります。隣接している領域は自領域の輪郭をトレースすることで見つけます。そして、全ての隣接している領域の中で、最も性質が近い領域と統合します。

// 面積の小さな領域を統合
void SplitMerge::absorb(void)
{
    if(m_minSize==0) return;

    DWORD a,b,n;
    do{
        n=0;
        for(b=1;b<m_number;b++)
        {
            if(m_pSize[b]>0 && m_pSize[b]<m_minSize)
            {
                // 隣接する最も平均濃度値の近い領域の番号を返す
                a=trace(b);
                if(a){ labeling(a,b); n++; }
            }
        }
    } while(n);
}

輪郭トレース

 輪郭をトレースする手順は以下のようになります。

  1. トレースの開始座標を設定する
  2. 現在の座標を中心に、左回り、または右回りに、他領域から自領域の番号に変化する位置を探す
  3. 変化した位置が輪郭なので、現在の座標を変化した位置に更新する
  4. 上記「2.」、「3.」の処理を、トレースの開始位置に戻ってくるまで繰り返す

 図4をご覧下さい。色付きの領域が自領域です。5番(現在の座標)を中心に、2番から左回りに調べた場合を考えてみましょう。2→1、1→4、4→7、と調べて、7→8 で他領域から自領域に変化しているので、7番の位置が輪郭だとわかります。

図4 トレース説明図
図4 トレース説明図

 実際には、常に同じ番号から調べ始めるわけではなく、新しい輪郭の位置に応じて、調べ始める番号を設定します。これは高速化のためというより、無限ループに陥ることを回避するためです。詳しくは『C言語で学ぶ実践画像処理』(オーム社)などをご覧下さい。

 プログラムはちょっと面倒ですが、ポイントは、switch文のcaseで輪郭が見つかるまでbreakを実行しないことです。この部分は分割・統合法とは直接関係ないので、読み飛ばしても構いません。

// 隣接する最も平均濃度値の近い領域の番号を返す
DWORD SplitMerge::trace(DWORD target)
{
    int xStart=m_pStartX[target]-1,yStart=m_pStartY[target];
    int x=xStart,y=yStart;
    DWORD from=0,to,n=0;
    BYTE vec=2;       // 次に調べる位置
    double sub,min=256;
    DWORD answer=0;

    // 左回りに輪郭トレース
    while(true)
    {
        if(n && x==xStart && y==yStart) // 始点に戻ってきた
        {
            return answer;
        }
        if(n>m_w*m_h) return 0; // 無限ループの可能性大
        // 優先順位を付けて調べる
        // (闇雲に調べると無限ループに陥る危険あり)
        switch(vec) 
        {
        case 6:     // 右中が輪郭かな?
            from=(x+1>=0 && x+1<m_w && y>=0 && y<m_h)?
                m_pLabel[x+1+y*m_w]:0;
            to=(x+1>=0 && x+1<m_w && y-1>=0 && y-1<m_h)?
                m_pLabel[x+1+(y-1)*m_w]:0;
            if(from!=target && to==target)
            {
                x=x+1; /* y=y; */
                vec=7; break;
            }
        case 3:     // 右上が輪郭かな?
            from=(x+1>=0 && x+1<m_w && y-1>=0 && y-1<m_h)?
                m_pLabel[x+1+(y-1)*m_w]:0;
            to=(x>=0 && x<m_w && y-1>=0 && y-1<m_h)?
                _pLabel[x+(y-1)*m_w]:0;
            if(from!=target && to==target)
            {
                x=x+1; y=y-1;
                vec=8; break;
            }
        case 2:     // 中上が輪郭かな?
            from=(x>=0 && x<m_w && y-1>=0 && y-1<m_h)?
                m_pLabel[x+(y-1)*m_w]:0;
            to=(x-1>=0 && x-1<m_w && y-1>=0 && y-1<m_h)?
                m_pLabel[x-1+(y-1)*m_w]:0;
            if(from!=target && to==target)
            {
                /* x=x; */ y=y-1;
                vec=9; break;
            }
        case 1:     // 左上が輪郭かな?
            from=(x-1>=0 && x-1<m_w && y-1>=0 && y-1<m_h)?
                m_pLabel[x-1+(y-1)*m_w]:0;
            to=(x-1>=0 && x-1<m_w && y>=0 && y<m_h)?
                m_pLabel[x-1+y*m_w]:0;
            if(from!=target && to==target)
            {
                x=x-1; y=y-1;
                vec=6; break;
            }
        case 4:     // 左中が輪郭かな?
            from=(x-1>=0 && x-1<m_w && y>=0 && y<m_h)?
                m_pLabel[x-1+y*m_w]:0;
            to=(x-1>=0 && x-1<m_w && y+1>=0 && y+1<m_h)?
                m_pLabel[x-1+(y+1)*m_w]:0;
            if(from!=target && to==target)
            {
                x=x-1; /* y=y; */
                vec=3; break;
            }
        case 7:     // 左下が輪郭かな?
            from=(x-1>=0 && x-1<m_w && y+1>=0 && y+1<m_h)?
                m_pLabel[x-1+(y+1)*m_w]:0;
            to=(x>=0 && x<m_w && y+1>=0 && y+1<m_h)?
                m_pLabel[x+(y+1)*m_w]:0;
            if(from!=target && to==target)
            {
                x=x-1; y=y+1;
                vec=2; break;
            }
        case 8:     // 中下が輪郭かな?
            from=(x>=0 && x<m_w && y+1>=0 && y+1<m_h)?
                m_pLabel[x+(y+1)*m_w]:0;
            to=(x+1>=0 && x+1<m_w && y+1>=0 && y+1<m_h)?
                m_pLabel[x+1+(y+1)*m_w]:0;
            if(from!=target && to==target)
            {
                /* x=x; */ y=y+1;
                vec=1; break;
            }
        case 9:     // 右下が輪郭かな?
            from=(x+1>=0 && x+1<m_w && y+1>=0 && y+1<m_h)?
                m_pLabel[x+1+(y+1)*m_w]:0;
            to=(x+1>=0 && x+1<m_w && y>=0 && y<m_h)?
                m_pLabel[x+1+y*m_w]:0;
            if(from!=target && to==target)
            {
                x=x+1; y=y+1;
                vec=4; break;
            }
            vec=6; continue;        // 一番上へ
        }
        if(from)        // 0 以外なら有効な領域の番号
        {
            sub=fabs(m_pAverage[target]-m_pAverage[from]);
            if(sub<min){ min=sub; answer=from; }
        }
        n++;      // 輪郭の画素数(周囲長ではない)
    }
}

描画

 描画はPaintLineメンバ関数が担当しています。ウィンドウプロシージャはWM_PAINTメッセージを処理するごとにPaintLineメンバ関数を呼び出します。描画にはSetPixel関数を用いていますが、SetPixel関数は低速なので、メインスレッドに割り当てられた時間内に処理が終わらないことが多々あります。DIBに描画した方が遙かに高速なのですが、使い勝手を優先して、このようにしました。

// 分割線を描画(本当は線じゃなくて点)
void SplitMerge::PaintLine(HDC hdc,COLORREF color)
{
    if(m_pLabel==NULL) return;       // error
    if(m_r==0 || m_b==0) return;      // error

    DWORD x,y;
    // 横方向の境界に点を打つ
    for(y=m_t;y<=m_b;y++){
        for(x=m_l;x<=m_r-1;x++){
            if(m_pLabel[x+y*m_w]!=m_pLabel[x+1+y*m_w]){
                SetPixel(hdc,x,y,color);
            }
        }
    }
    // 縦方向の境界に点を打つ
    for(x=m_l;x<=m_r;x++){
        for(y=m_t;y<=m_b-1;y++){
            if(m_pLabel[x+y*m_w]!=m_pLabel[x+(y+1)*m_w]){
                SetPixel(hdc,x,y,color);
            }
        }
    }
}
クラス型変数の初期値
 失敗談をもう一つ。メンバ変数の初期値をご存知でしょうか。通常はコンストラクタで初期化しますが、初期化しなかったら? という問題です。Windowsプログラミングでは、クラス型変数は静的変数として宣言することがほとんどなので、0またはNULLだと思い込んでいる方もいるかもしれません(過去の私だけかもしれませんが)。しかし、クラス型変数も普通の変数と同じで、動的変数として宣言されれば不定値で初期化され、静的変数として宣言されれば0またはNULLで初期化されます。従って、初期化処理は省略しちゃいけないよ、という教訓を得たのでした。

ウィンドウプロシージャ

 下記のプログラムは抜粋ですが、このようにSplitMergeクラスは使います。BmpIOクラスの主なメンバ関数については付属のドキュメントをご覧下さい。

static BmpIO s_bio;
static SplitMerge s_sm;

case WM_KEYDOWN:
    s_bio.LoadPicture(hWnd,BIT24);
    s_sm.SetImage(s_bio.GetPixel(),s_bio.GetWidth(),
        s_bio.GetHeight(),s_bio.GetBit()/8);
    s_sm.SetThreshold(2,0.9,10,48);
    s_sm.SplitAndMerge(0,0,s_bio.GetWidth()-1,s_bio.GetHeight()-1);
    return 0;

HDC hdc;
PAINTSTRUCT ps;

case WM_PAINT:
    hdc=BeginPaint(hWnd,&ps);
    BitBlt(hdc,0,0,s_bio.GetWidth(),s_bio.GetHeight(),
        s_bio.GetHDC(),0,0,SRCCOPY);
    s_sm.PaintLine(hdc,RGB(255,0,0));
    EndPaint(hWnd,&ps);
    return 0;

まとめ

 領域分割の主な目的は、分割して得られた領域の形、つまり対象物の概形からそれが何であるか、を判断することです。ピクセル単位の変化は重要ではありません。従って、分割・統合法においても、精度を上げたいからと細かく分割して統合すれば良いというものではありません。過分割で概形が崩れる危険があるからです。最適な分割および統合の閾値を探すには、用途に合った実験を行って調べていくという地道な作業が必要です。

 概形を得るための別のアプローチとしては、画像自体をぼかしてから処理するという方法もあります。この場合は、ガウスフィルタなどで大きくぼかした画像を処理するとよいでしょう。

 今回は、分割および統合の判断基準となる画像の性質として、濃度値を用いました。しかし、単一の色成分だけを用いた場合、細かく分割して統合した結果と減色処理の結果とが同じになり、お勧めできません。結果は同じになるのに、処理時間は何倍も遅くなるからです。

 同様に、適度に分割したとしても、同じような結果になります。処理に時間がかかる、という欠点を補うためにも、例えば、複数の色成分を用いるといった、分割・統合法の利点をもっと活かすべきです。

 色そのものにこだわる必要もありません。色のパターン、つまり模様に注目したり、その形に注目してみるのも面白いでしょう。

 他にも、ヒストグラム以外の判定基準を用いたり、微分した画像の特徴量を用いてみたり、など色々と工夫してみて下さい。個人的には、色そのものを用いた以外の方法を開発して欲しいです。

 お分かりのように、分割・統合法の欠点は、処理に時間がかかることです。いろいろ高速化は可能ですが、分割や統合の判定方法が複雑になるにつれて、処理時間も増加します。しかし、複雑で自由な条件を設定できる事こそが分割・統合法の利点なので、他の手法では真似できないような条件を設定し、処理に時間がかかるという欠点を補いましょう。

 最後に、分割・統合法の使用例として、人物画像から服を抽出するという処理ができるかどうか考えてみましょう。ここでの人物画像とは、背景を除去して人物だけが写っている画像のこととします。人物は正面を向いて立っており、姿勢は自由とします。

 この問題の第一目的は、服の概形を得ることなので(模様や柄の抽出は、とりあえず考えません)、当然、シャツとズボンは別々の領域として分割されなければなりません。しかし、色はもちろん、模様や柄、そして人物の姿勢によって形さえも様々な、シャツとズボンを別々の領域として分割することは、かなりの難問になります。

 実は、様々な要因に左右されない性質が見つかっていないのが現状であり、同時に、分割・統合法の限界を感じさせます。研究は現在でも行われていますので、皆さんも一緒に考えて頂ければと思います。

 皆様のご意見・ご感想を是非ともお聞かせ下さい。フォーラムまでお願いします。

参考資料

修正履歴

この記事は参考になりましたか?

  • X ポスト
  • このエントリーをはてなブックマークに追加
特集記事連載記事一覧

もっと読む

この記事の著者

ひよこ(ヒヨコ)

職業ゲームプログラマーを志す学生です。

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

  • X ポスト
  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/167 2006/05/26 15:42

おすすめ

アクセスランキング

アクセスランキング

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング