なめこ備忘録

プログラミングに関する備忘録や経験したこと,考えたことなど好き勝手書きます.

[tex: ]

画像処理.js -エッジ検出 2

こんにちは,なめこです.

jsでの画像処理2回目です.
前回の記事はこちらになります.

nanameko.hatenablog.com

今回はなんとなくエッジ検出について調べた時に見つけた手法を実装してみたいと思います.

こちらの記事の既存手法と提案手法(色情報と大津の閾値判別法を用いたエッジ検出)を両方実装していきます.
(既存手法の名称はわからなかったのでここでは「動的色境界によるエッジ検出」とします)

※詳細なアルゴリズムに関しては書かれていなかったので,正確には"それっぽい"実装となります.全くの見当外れではないとは思いますが100%正しいものではないと思いますので悪しからず

www.ail.cs.gunma-u.ac.jp

色情報を用いたエッジ検出のメリット

先に今回のような色情報RGBを使用することのメリットを簡単に説明しておきます.

まず前提として,カラー画像からグレースケール画像へ変換する際は以下のような式による計算が行われています.

$$Y = 0.299\times R+0.587\times G+0.114\times B$$

式に示すまでもないとは思いますがRGB -> Grayは非可逆な変換であり,RGBの組み合わせとYは一対一での対応はできません.
色によって係数が大きく異なるのも特徴的かもしれません.
そのためカラー画像の時に差が大きく明確であった境界も,グレースケール画像に変換することでその差が小さくなってしまう場合があります.

例えばRGBで表現された以下の2つの色 $$R:255, G:0, B:255$$ $$R:0, G,180, B:0$$ これはカラー画像においては全く違いますが,グレースケール画像に変換するとほぼ同じものになります.

つまりこういった色変化によって生じるエッジがある場合,グレースケール画像を前提とした手法では検出できません(あるいは検出できたとしても弱いエッジとなるためその後の処理で切り捨てられる可能性がある).

色の境界を用いたエッジ検出を行うことで,これらのエッジを検出することができます.
実用例としては道路標識検出などがあるようです. 駐車禁止の道路標識などがグレースケールでの手法だと検出できないようですね.

動的色境界によるエッジ検出

ひとまず簡単そうな既存手法から始めます.

アルゴリズム

記事に示されていた処理の流れとしては以下の通りです.

  1. カラー画像をRGB成分に分解
  2. 分解した画像を2値化
  3. 三つの画像を統合(3bit画像の生成)
  4. 色の変化している部分をエッジとして判定

3を経た上での4の処理の詳細がちょっとよくわからなかったので,今回は以下の流れで処理することにしました.

  1. カラー画像をRGB成分に分解
  2. 分解した画像を2値化
  3. 3つの画像それぞれでエッジ検出
  4. 3つのエッジ画像のいずれかでエッジであればエッジとして判定

2値化には大津の閾値判別法(判別分析法)を用います(2値化の代表的な手法なので詳細は省きます).

エッジ検出は既にSobelフィルタを実装済みなのでこちらの方が楽だと判断しました.
3bit画像で色変化が起きている部分を~という処理と大きな違いはないように思います.

実装

今回カラー画像として読み込んであるので,imgは3次元配列となります. ちなみにbinは2値化,sobelはSobelフィルタを適用する関数となります.実装の詳細はGitHubへどうぞ.

const imgBGR = [];
const imgBGRbin = [];
const imgBGRsobel = [];

// 色ごとに操作
for(let n=0; n<3; n++){
    // 単色のデータ取得
    imgBGR[n] = map(img, v=>v[n]);
    // 2値化
    imgBGRbin[n] = bin(imgBGR[n], {maxVal:255});
    // エッジ検出
    imgBGRsobel[n] = sobel(imgBGRbin[n]).sobelImg;
}
// RGB画像に戻す処理(3bit画像の生成)
const imgBin = map(imgBGRbin[0], (v,i,j) => [v, imgBGRbin[1][j][i], imgBGRbin[2][j][i]]);
// いずれかの色画像にエッジがあればカラー画像のエッジとして処理
const imgEdge = map(imgBGRsobel[0], (v,i,j) => (v+imgBGRsobel[1][j][i]+imgBGRsobel[2][j][i])>0?255:0);

RGB画像って言ってるのになんで変数名がBGRなのかというと,OpenCVではBGRの順になっているからです.まあ別にどの色を使うとかは考える必要はないのでなんとなく合わせてるだけですが.

単色のデータの取り出しは比較的簡単でしたが,RGB画像に戻す処理がちょっと汚いコードになってます.
綺麗な処理を思い付いたらしれっと直すかもしれません.メモリアクセス的に残念な気がしますし.

最終的なエッジ画像の作り方も結構雑です.3色のエッジ画像の画素を足して値があればエッジにしています.
今回は勾配の大きさによる切り捨ては行なっていません.

実行結果

2値化後の3bit画像及び今回の手法でのエッジ検出の結果を示します.

前回のSobelやCanny法によるエッジ抽出と比較するとよくわかりますが,グラデーションなどで境界が曖昧な部分もかなり綺麗に検出できています.
ただその代わりに陰影の部分に強く反応してしまっています.

f:id:NAMEKO:20190217040626p:plain
3bit画像

f:id:NAMEKO:20190217040712p:plain
エッジ抽出結果

色情報と大津の閾値判別法を用いたエッジ検出

既存手法である動的色境界によるエッジ検出の改良版です.

既存手法では明暗が激しい画像において(逆光の強い画像など),明暗の境界として表現されるのエッジしか検出できない問題があるため,それへの対応を行った手法です.

アルゴリズム

基本的には上記の既存手法と同様ですが,この手法では画像を小領域に分割してそれぞれに処理を行います.

大津の閾値判別法による閾値決定を小領域ごとに行うことで,小領域ごとの明るさの違いに対応した閾値が定まります.
ただしこれでは小さすぎる変化にも反応してノイズとして出てしまうので,小領域全体の明るさに対応したまた別の閾値を定め,その閾値以下であればその小領域内のその色の変化を無視するようにしています.

また最初にノイズ除去のためのガウシアンフィルタをかけています.

処理の流れとしては以下の通りです.

  1. 画像全体(各色ごと)にガウシアンフィルタをかけてノイズ除去
  2. 画像を小領域に分割
  3. 分割画像ごとに2値化(大津の閾値判別法 +a )
  4. 2値化した分割画像ごとにエッジ検出
  5. 分割画像の結合

このうち3の+aの詳細は不明だったので,「小領域全体の輝度の平均」と「大津の閾値判別法で求められる分散」の比が一定以下となる場合に色変化をなくす,という方法を適用することにします.
完全に「こんな感じかな?」って感じの思いつきレベルですのでどれほど正しいかは不明です

実装

let splitsize = 64;
let threshold = 1;

// ガウシアンフィルタ設定
const gaussianFilterSize = 5;
const gaussianFilterSizeHalf = (gaussianFilterSize-1)/2;
const sigma = gaussianFilterSizeHalf/2;
const gaussianFilter = Array.from(new Array(gaussianFilterSize)).map((_, j) =>
    Array.from(new Array(gaussianFilterSize)).map((_, i) => {
        return math.exp(-(j*j+i*i)/(2/sigma/sigma))/(2*math.PI*sigma*sigma);
    }));

// 変数準備
const imgBGR = [];
const imgBGRbin = [];
const imgBGRsobel = [];
const sizes = image.sizes;
const rowNum = sizes[0]/splitsize;
const colNum = sizes[1]/splitsize;

// 色ごとに操作
for(let n=0; n<3; n++){
    // 単色のデータ取得
    const v = map(img, v=>v[n]);
    // ガウシアンフィルタの適用
    const vGaus = conv.conv(v, image.sizes, gaussianFilter, [gaussianFilterSize,gaussianFilterSize], {mode:conv.EXPAND});
    const val = map(v, v=>0);
    const valBin = map(v, v=>0);

    // 小領域ごとに処理
    for(let j=0; j<rowNum; j++){
        for(let i=0; i<colNum; i++){
            const img_small = v.slice(j*splitsize, (j+1)*splitsize).map(v => v.slice(i*splitsize, (i+1)*splitsize));
            
            // otsuの2値化
            const img_smallbin = (img => {
                maxVal = 255;
                const hystgram = Array.from(new Array(256)).fill(0);
                let t = 0;
                let sum = 0;
                let sumPow = 0;
                let omg = img.length*img[0].length;
                for(let V of img){
                    for(let v of V){
                        hystgram[v]++;
                        sum += v;
                        sumPow += v*v;
                    }
                }
                // 全体平均の計算
                const mean = sum/omg;
                if(mean == 0){
                    return map(img, v => 0);
                }

                let omg1 = 0;
                let sum1 = 0;
                let sigmab_max = 0;
                for(let [i, n] of hystgram.entries()){
                    omg1 += n;
                    sum1 += i*n;
                    const omg2 = omg - omg1;
                    const sum2 = sum - sum1;
                    const m = sum1/omg1 - sum2/omg2;
                    const sigmab = omg1*omg2*m*m;
                    if(sigmab > sigmab_max){
                        sigmab_max = sigmab;
                        t = i;
                    }
                }
                sigmab_max /= omg*omg;
                // 全体平均と分散による閾値処理
                if(sigmab_max/mean < threshold){
                    return map(img, v => 0);
                }
                return map(img, v => t<v?maxVal:0);
            })(img_small);

            // Sobelフィルタの適用
            const img_smallsobel = sobel(img_smallbin).sobelImg;

            // 画像の結合処理
            for(let l=0; l<splitsize; l++){
                for(let k=0; k<splitsize; k++){
                    if(j*splitsize+l < sizes[0] && i*splitsize+k < sizes[1]){
                        valBin[j*splitsize+l][i*splitsize+k] = img_smallbin[l][k];
                        val[j*splitsize+l][i*splitsize+k] = img_smallsobel[l][k];
                    }
                }
            }
        }
    }
    // 2値化画像
    imgBGRbin[n] = valBin;
    // エッジ画像
    imgBGRsobel[n] = val;
}
// RGB画像に戻す処理
const imgBin = map(imgBGRbin[0], (v,i,j) => [v, imgBGRbin[1][j][i], imgBGRbin[2][j][i]]);
// いずれかの色画像にエッジがあればカラー画像のエッジとして処理
const imgEdge = map(imgBGRsobel[0], (v,i,j) => (v+imgBGRsobel[1][j][i]+imgBGRsobel[2][j][i])>0?255:0);

記事では小領域を50x50としていましたが,キリのいい数字が良かったので64x64としました.
また閾値は何度か試してみてとりあえず設定した値です.

既存手法の実装での2値化処理は用意しておいたモジュールに投げてましたが,今回は大津の閾値判別法の処理途中で求められる画素の総和や分散などが必要だったためその場で即時関数として実装しています.

分割・結合処理は正直なところかなり無駄が多いのでもう少しマシな方法を考えることにします.

実行結果

小領域ごとに2値化している関係で少し妙な感じの3bit画像ができています.
また,3bit画像を見るとわかりますが,エッジが小領域の端の部分にしか含まれていない場合は高確率で切り捨てられています.
これが原因で一部エッジが途切れていますね.

ただ既存手法で反応が強かった陰影には今回ほとんど反応していないので一長一短といったところでしょうか.

ちなみに小領域のサイズを変えると結果が全然違ってきます.画像によって最適な小領域のサイズが全然違ったので色々試してみる必要がありそうです.

f:id:NAMEKO:20190217042532p:plain
3bit画像(領域分割して生成したものを再度結合)

f:id:NAMEKO:20190217042642p:plain
エッジ検出結果

まとめ

今回は色情報を用いたエッジ検出を行いました.

基本的には,グレースケールの時に使っていた処理を色ごとに使って後で結合するといった流れのようです.
前回の比較もしたかったのでイラストに対して実行しましたが,自然画像(特に道路標識が写っている逆光の強い画像)に対して実行してみるとその有用性が理解できるので面白いかもしれません.

今回の実装は以下のGithubのedge/dinamicColorBoundaryEdge.js(既存手法),edge/colorEdge.js(提案手法)にあたります.

github.com

次回は「流れ場を考慮したエッジ検出」を実装する予定です.
たぶん次でエッジ検出は一旦終わりですかね.

参考文献

OpenCV: Color conversions

色情報と大津の閾値判別法を用いたエッジ検出 - OhtaLabWiki

判別分析法(大津の二値化) 画像処理ソリューション