OpenCVに頼らないでPython+Numpy+PILで画像処理のフィルタを1から作って理解する on Mac/Raspberry Pi

f:id:karaage:20170724112939j:plain:w640

2017/12/18 Numpyでの画像処理に関してリンク
2017/07/31 Numpyでfor文を使わない書き方に関して追記

画像処理を基礎から学ぶ

 私は、カメラが好きなこともあり、画像処理に関しても興味あります。一般的には、RAW現像とかPhotoShopのテクニックなどを身につける人が多いようですが、私の場合は、何故かpythonやOpenCVという便利な画像処理ライブラリを使って画像処理ソフトを自作するところから始めようとしています。

 ただ、OpenCVは便利なのですが、便利さがゆえにブラックスボックス的に使ってしまっているのが気になっていました。やっぱり内部で何をしているかわかっていないと、ちょっとAPIに無い処理をしたいときや、問題が発生したときに何をどうすれば良いのか全然分からないですし、OpenCVを使うまでもない処理にOpenCVを使ってしまうのもよろしく無いなと思います。Open CVインストール手間ですし結構時間かかるので(特にRaspberry Piとかだと)。

 基礎から理解するには、OpenCVのソースを読むのもよいのですが、やはり自分で一度作ってみるのが一番。というわけで、OpenCVに頼らず基礎的な画像処理のライブラリのPILと行列計算のライブラリのNumpyだけを使って、1から簡単な画像処理のフィルタを作って理解してみようかなと思います。

 TOKIO的に言えば

「画像処理のソフトを作ってください」

「それはどういうレベルでつくるの? Open CVから?フルスクラッチで?」

 という感じです。ライブラリは使っているので、フルスクラッチは大げさですかね。今回の記事はMacを想定していますが、Raspberry PiでもOKです。

画像処理のフィルタを1から作る

環境構築

 環境構築は以下記事を参照して下さい。Macの場合、Raspberry Piの場合に分けてセットアップ方法を解説しています。LinuxでもOKと思いますが、ここでは環境設定は省略します。よしなに。

 上記だけだとNumpyが入らないので、以下コマンド実行してNumpyをインストールしましょう。

$ pip install numpy

 Raspberry Piの場合は、以下でパッケージをインストールすると良いかもしれません

$ sudo apt-get install python-numpy

 Windowsでも色々頑張れば動くと思います。このサイトでは説明しません(私に環境がないので)。すみません。

 セットアップも含め、python2での動作を前提にしていますが、プログラム自体はpython3でも動作します。

画像処理のフィルタの仕組み

 画像処理のフィルタですが、実は多種多様の様々なフィルタがあります。今回はその中でも代表的な、ぼかし処理とかエッジ強調をするための画像フィルタに関して取り上げます。基本的な手法が、画像の全画素に対して、周囲の画素の値に、ある係数を掛け合わせたものを代入するという処理をするというものです。この係数の行列をフィルタ(カーネル)と呼びます。言葉だとうまく説明できないので、10x10の画像を例にして、図と数式で説明すると以下のような感じです。

f:id:karaage:20170724112939j:plain:w640

 右の行列がフィルタです。この処理を(i, j)(0,0)〜(10,10)全ての画素に対して実施するのです。わ、わかる?分からなかったらすみません。以下のサイトとか見てみた方がわかりやすいかもしれません。

コンボリューション(畳み込み処理)を実装してみる - Qiita

画像処理の数式を見て石になった時のための、金の針 - Qiita

 こういった計算のことを畳み込み(畳み込み演算・畳み込み積分)と言います。上記は、モノクロ画像の例ですが、これをRGBの3チャネル分実施すればカラー画像となります。

 仕組みが分かったら、早速実装してみます。ソースは、雑誌Interfaceの2017年5月号を参考にしました。

Interface(インターフェース) 2017年 05 月号

Interface(インターフェース) 2017年 05 月号

 この雑誌は、自分が画像処理のフィルタを1から勉強しようと思ったきっかけになった本で、様々な画像処理の手法とソースコードが惜しげもなく紹介されています。ただし、言語はCで書かれているので、今回pythonに自力で移植した形になります。

 移植に関して、参考にしたサイトは本記事の最後で紹介させていただきます。

入力をそのまま出力するフィルタ

 まずは、以下のようなフィルタを例に、プログラムを書いてみます。 f:id:karaage:20170724112943j:plain:w640

 このフィルタは、自身の画素だけに1をかける。すなわち何も処理をしないフィルタとなります。プログラムは以下のような感じになります。

from PIL import Image
import numpy as np
import sys

filter = [0, 0, 0, 0, 1, 0, 0, 0, 0]

def image_process(src):
    width, height = src.size
    dst = Image.new('RGB', (width, height))

    img_pixels = np.array([[src.getpixel((x,y)) for y in range(height)] for x in range(width)])
    color = np.zeros((len(filter), 3))

    for y in range(1, height-1):
        for x in range(1, width-1):
            color[0] = img_pixels[x-1][y-1]
            color[1] = img_pixels[x-1][y]
            color[2] = img_pixels[x-1][y+1]
            color[3] = img_pixels[x][y-1]
            color[4] = img_pixels[x][y]
            color[5] = img_pixels[x][y+1]
            color[6] = img_pixels[x+1][y-1]
            color[7] = img_pixels[x+1][y]
            color[8]= img_pixels[x+1][y+1]

            sum_color = np.zeros(3)
            for num in range(len(filter)):
                sum_color += color[num] * filter[num]

            r,g,b = map(int, (sum_color))
            r = min([r, 255])
            r = max([r, 0])
            g = min([g, 255])
            g = max([g, 0])
            b = min([b, 255])
            b = max([b, 0])

            dst.putpixel((x,y), (r,g,b))

    return dst

if __name__ == '__main__':
    param = sys.argv
    if (len(param) != 2):
        print ("Usage: $ python " + param[0] + " sample.jpg")
        quit()

    # open image file
    try:
        input_img = Image.open(param[1])
    except:
        print ('faild to load %s' % param[1])
        quit()

    if input_img is None:
        print ('faild to load %s' % param[1])
        quit()

    output_img = image_process(input_img)
    output_img.save("filtered_" + param[1])
    output_img.show()

 5行目の以下がフィルタの配列です。

filter = [0, 0, 0, 0, 1, 0, 0, 0, 0]

 以下のような操作で、画像をnumpyの配列にぶちこむことができますので、後は1画素ごとに操作をしてputpixelで吐き出すだけです。

img_pixels = np.array([[src.getpixel((x,y)) for y in range(height)] for x in range(width)])

 プログラムに関して、詳細は説明しませんが、こんな感じの書き方で読み込んだ画像の全画素に対して、フィルタリング処理ができます。興味がある人は自身で調べてみてください。そんなに難しいことはしていません。

 実行方法は、プログラムをfilter.pyという名前で保存して、と対象の画像sample.jpgを同じフォルダに配置して、以下コマンドを実行するだけです。

$ python filter.py sample.jpg

 百聞は一見に如かずです。いつものフリー素材で試してみましょう。ロンスタさん(id:lonestartx) いつもありがとうございます。

f:id:karaage:20170724130845j:plain:w640  イケメンが

 フィルタを通すと

f:id:karaage:20170724130845j:plain:w640  はいそのまま!

 何も面白くないですね。

ぼかしフィルタ

 次は、以下のようなフィルタを使ってみます。 f:id:karaage:20170724112947j:plain:w640

 周りの画素と平均することになるので、ぼやっとしたフィルタになることが期待できそうですね。

 具体的には、先ほどのプログラムの5行目の配列を以下に修正します(python2)。

filter = [1.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0, 1.0/9.0]

 そうすると

f:id:karaage:20170724130845j:plain:w640  イケメンが

f:id:karaage:20170724131821j:plain:w640
 ちょっともやっとした!

 今回は、フィルタの値は全て同じ値の移動平均フィルタ(平滑化フィルタとも呼ばれる)を試しましたが。遠くの画素ほど、指数関数的に値を小さくするような値にすると、ガウシアンフィルタ(Gaussian filter)と呼ばれる代表的なボカシフィルタになります。

平滑化(移動平均、ガウシアン)フィルタ 画像処理ソリューション

エッジフィルタ

 今度は以下のようなフィルタを試してみます。

f:id:karaage:20170724112953j:plain:w640

 なんとなくですが、縦方向の画素だけとっているので、縦方向が強調されそうですね。先ほどの画像で試してみると。

f:id:karaage:20170724132245j:plain:w640  縦が強調された!

 同様に以下のフィルタで試してみましょう。今度はどうなるでしょうか?

f:id:karaage:20170724112957j:plain:w640

 同じように実行すると…

f:id:karaage:20170724132439j:plain:w640
 今度は横が強調された!

 じゃあこれらのフィルタを足し算したような以下のようなフィルタはどうでしょうか? f:id:karaage:20170724113002j:plain:w640

 試してみると…

f:id:karaage:20170724132208j:plain:w640
 両方強調された!

 ちなみに、この縦横両方強調するようなフィルタはラプラシアンフィルタ(Laplacian Filter)と呼ばれます。

 こんな感じで、フィルタの値を色々変えると、画像の様々な特徴を抽出することができることがわかります。

画像のフィルタ処理はディープラーニングにも深い関わりがある

 実は、こういった画像処理の基礎は、最近話題のディープラーニングにも深い関わりがあります。ディープラーニングが得意なのは、画像認識で以下のような特徴があると以前ご紹介しました。

f:id:karaage:20170625212919j:plain:w640

 上図の詳細は以下の記事参照下さい。

 ディープラーニングは、特徴量の抽出を自動でやってくれるのが凄いのですが、ここの部分は具体的に何をやっているかというと、実はまさに先ほど紹介したフィルタの値の設計なのです。

 3x3で縦を強調するという単純な例ならまだしも、例えば画像から猫っぽい特徴を抽出するために、5x5や10x10のサイズのフィルタを2回重ねて…なんて人間には設計できる気がしませんね。そんなフィルタを自動で設計できるのが、ディープラーニングで有名なCNN(Convolutional Neural Network)というニューラルネットワークです。Convolutionalは日本語に訳すと「畳み込み」でまさに先ほどのフィルタの計算のことです。

 また、ディープラーニングにおいて、フィルタ(ニューラルネットワーク)は自動で設計してくれるのですが、そのために必要な入力データの質も重要になってきて、あらかじめ余分な情報のノイズを除去(フィルタリング)することが非常に大切になってくるため、ここにも基礎的なフィルタリングといった画像処理は非常に重要になってくるのです(前処理と呼ばれたりします)。

 余談ですが、ディープラーニングのCNNは囲碁で世界一のプロ棋士に勝ったことで有名なAlphaGoにも使われています。囲碁の黒白の19×19の盤面を、モノクロ画像に見立てて学習しているのです。凄い事考えますね。AlphaGoの詳細は、以下の記事が詳しいです。

Googleが出した囲碁ソフト「AlphaGo」の論文を翻訳して解説してみる。 - 7rpn’s blog: うわああああな日常

 ちなみに将棋は、駒の種類が多いので囲碁に比べるとCNNとの相性はよく無いため、ディープラーニングはまさにこれから使われだしているというところです。今までプロ棋士との勝負に使われている将棋ソフトは、基本的にはディープラーニング以前の機械学習が用いられていると思います(私の知る限り)。将棋のソフトも、ディープラーニングを使うことでこれから更に強くなっていくかもしれませんね。

まとめ

 画像処理のフィルタに関して、簡単にまとめて実際にpythonで実装してみました。ただ、実際に実行してみると分かるのですが、今回のプログラムって物凄い遅いです。numpyってfor文何度も回すような計算って不得意なのですよね…OpenCVだと、そういったところも最適化されていますし、今回紹介したようなフィルタは全て関数として用意されています。じゃあやっぱりOpenCVが良いじゃんとなってしまうわけで、実際そうなのです。こういった基礎的なところを知っておくと良いことあるかもよ、という私の余計なお節介と思って下さい(今更の衝撃的な告白)。

 まあ、OpenCVが使えないと何もできないというのもカッコ悪いですしね。OpenCVが使えない環境や事情がある場合もあると思いますし(今時滅多に無いかな?)。今回のような基礎なら、覚えておくと、どのような環境・言語でも応用がきくと思います。またこういった基礎的な技術が、最先端のディープラーニングでも重要で多用されているというのも中々味わい深いものがあります。用語自体は、Photoshopなどの画像処理でも使われるものたくさんありますしね。何事も基礎は大切ということです。

 今回使用したプログラムは、以下のGitHubのリポジトリにもアップしてあります。参考まで

追記:numpyでfor文を回さないで書く方法

 twitterでnumpyでfor文を使わずに書く方法を教えていただきました。

 言い訳ではないですが、for文をアホみたいに回しているのは、移植元のCのコードを極力そのまま持ってきて原理の理解に努めたかったのが理由です。ただ、本当はfor文を使わずに書く書き方も絶対あると思っていて、調べて紹介したかったのですが、私の力不足でできませんでした。これは言い訳しようない本音です。こうやって教えてもらえて本当に助かりました。試せてないですが、行列計算はnumpyの得意とするところなので、相当早くなると思います。

 紹介してもらった以下のサイトも、非常に詳しく書いてありためになりました。この記事書かないでここにリンク貼って置くだけでもよかったかもしれません(笑)。まぁ自分の勉強になったからよしとしましょう(超前向き)。

行列による画像処理 基礎編&目次 ~Python画像処理の再発明家~ - Qiita

行列による畳み込みフィルタリング編 ~Python画像処理の再発明家~ - Qiita

さらに追記:畳み込み警察に捕まりました

 畳み込み警察こと id:cruller さんから、以下指摘を頂戴いたしました。

 畳み込みのときは、カーネルを各軸に対して反転(180°回転)して計算するのが正しいそうです。反転しないものは、(相互)相関ではないかという主張です。相互相関とか大学の時に習ったような…完全に忘れてしまいましたね。勉強しなおさなきゃ。

 ただ、最近画像処理やCNN(ディープラーニング)関係の本を4,5冊くらい読んでいるのですが、どれも反転してないのですよね。分野の違いなのか…謎は深まるばかりです。

関連記事

参考記事

初めてのPython画像処理 - Qiita

画像処理の数式を見て石になった時のための、金の針 - Qiita

NumPyでの画像のData Augmentationまとめ - kumilog.train