畳み込みニューラルネットワークの基礎
本章では、畳み込みニューラルネットワークの基礎とそれに必要な画像処理について学んでいきます。画像処理を学ぶにあたり、そもそも画像とは一体どんな形で扱われるのかを確認していきましょう。次章では、本章で学んだ知識をもとに実装に取り組みます。
本章の構成
- 画像処理の実装
- 画像処理の基礎
- 畳み込みニューラルネットワーク
画像処理の実装
畳み込みニューラルネットワークを学ぶために、まず実装を通して画像データと画像処理の概要を掴みましょう。解説は Python で画像処理をする際によく使用されるパッケージである OpenCV と Pillow を使用して進めます。
import numpy as np
import matplotlib.pyplot as plt
# OpenCV のインポート
import cv2
OpenCV で画像を読み込む際には imread()
を使用します。引数には画像ファイルのディレクトリを指定します。
# 画像の読み込み
img = cv2.imread('sample.png')
OpenCV で読み込まれた画像は NumPy の ndarray
で扱われるため、これまでの慣れた操作をそのまま使用できます。
# 変数の型を確認
type(img)
# 形の確認
img.shape
# データ型の確認
img.dtype
ここで uint8
とは unsigned int の略で、符号なし(正の値のみ)の 8bit の整数であり、0 ~255 までの値を表現可能です。また、次節で詳しく説明します。
OpenCV では画像が BGR の順で格納されているため、Matplotlib を用いた画像の描画を行った際に、青みの強い色合いで表示されてしまいます。
plt.imshow(img)
この理由は Matplotlib が画像が RGB の順で格納されていることを標準としているためです。そのため、正しい色合いで表示するには、BGR を RGB の順に変換しておく必要があります。
ここで OpenCV にはチャネルを変換する関数が準備されており、cvtColor()
で RGB や BGR、HSV などの様々な種類を相互に変換することができます。最初の引数ではデータを指定し、2 つ目の引数に変換後の構成と変換前の構成を示します。例えば今回のように BGR の画像を RGB に変換する場合には COLOR_BGR2RGB
とすると、名前の通り BGR から RGB への変換となります。
# BGR -> RGB
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
plt.imshow(img_rgb);
RGB の画像が表示できていることが確認できました。
Pillow
Pillow は、元々 Python Imaging Library から派生して作られた経緯があり、頭文字を取って PIL
という名前で登録されています。様々なファイル形式のサポートや画像表示、画像の読み込みなど、広範囲な画像処理機能を備えており、非常に実用性の高いパッケージです。
詳細はこちらの公式ドキュメントを参照して下さい。
from PIL import Image
画像の読み込みには、Image.open()
を使用します。引数には画像ファイルのディレクトリを指定します。
# 画像の読み込み
img = Image.open('sample.png')
OpenCV で読み込まれた画像は NumPy の ndarray でしたが、Pillow の場合を確認してみましょう。
# 型の確認
type(img)
OpenCV で読み込んだ場合の NumPy の ndarray ではなく、Pillow の PngImageFile というものである事が確認できます。しかし、Pillow で読み込まれた画像はノートブック上で変数名のみのコードの実行で画像を表示することができます。
# 画像の表示
img
OpenCV と Pillow の大きな違いとして、見た目にはわかりにくいのですが、OpenCV は BGR の順で変数に格納されており、Pillow は RGB の順で格納されています。この点を間違えると、学習は OpenCV で行い、推論は Pillow で行った場合、予測結果が期待とは異なる結果になるので気を付けてください。
Pillow で読み込んだ画像を NumPy の ndarray に変換するには、NumPy の array
に渡すだけです。
# ndarray に変換
img = np.array(img)
# 型の確認
type(img)
# 形の確認
img.shape
# データ型の確認
img.dtype
もちろんこの他にも Pillow には便利な機能が多くありますが、今は OpenCV と Pillow というものが存在する、程度の認識で大丈夫です。今後使う必要が出てきた際に深く理解していきましょう。
また、Pillow と OpenCV の違いをまとめると下記になります。
Pillow | OpenCV | |
---|---|---|
読み込み時の型 | Pillow - PngImageFile | NumPy - ndarray |
チャンネル | RGB | BGR |
基本的な機能に両者大きな差異はありません。どちらを使用するかは実際に使用して、使いやすいと思うものを選択して下さい。
グレースケール変換
代表的な処理であるグレースケール変換を施してみましょう。グレースケール変換とは、カラー画像のような複数のチャンネル数を持つ画像を、1 チャンネルに数を減らす処理になります。3 チャンネルは 1 チャンネルの 3 倍の大きさを意味します。チャンネル数を減らすことにより計算量を減らすことができるメリットがあります。 OpenCV を用いての実装方法を確認します。グレースケール変換は BGR から RGB への変換に使用した cvtColor()
を使用します。
# グレースケール変換
img_gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
# 画像の表示
plt.imshow(img_gray);
白黒ではない色合いで画像が表示されました。これは Matplotlib では、RGB の順の 3 チャンネルの画像の入力を標準としているためです。グレースケールで表示するにはグレースケールであることを宣言してからプロットします。
imshow()
の引数に cmap='gray'
と入力します。cmap
とは Matplotlib で準備されている色合いのことであり、グレースケールを指定しています。他の方法に関してはこちらをご参照してください。
img_gray.shape, img.shape
# 画像を表示
plt.imshow(img_gray, cmap='gray');
こちらで正しく表示されました。形を確認してみると、グレースケール変換を施したことによって 3 チャンネルあった画像が 1 チャンネルになっていることが分かります。
画像処理の基礎
まずあまり説明をせずに実装から入りました。最初に画像処理とはなにかしらの変換を行うことだというイメージを共有したかったためです。それではここから詳しく画像の構造やどのような理論で処理が行われているのかを詳しく確認していきましょう。本節の内容が畳み込みニューラルネットワークを理解する上での基礎部分になりますので、しっかり学んでいきましょう。
画像とは
最初に、皆さんがいつも撮影をしたり、見ている画像とはどういう構造になっているのか考えてみましょう。
いつも何気なく見ている画像を詳しく見ていくと、一般的には上記の図のような構造をしています。高さ (Height) と幅 (width)、そして奥行き (channel) という情報を持っています。一般的に用いられるのは、RGB という光の三原色である赤 (Red)、緑 (Green)、青 (Blue) の 3 枚の画像を重ね合わせて表現されています。
この画像のひとつの正方形を ピクセル (pixel) と表現し、各ピクセルにはそれぞれ輝度と呼ばれる光の明るさを表した値が格納されています。ピクセルがどれくらい集まっているのかを表す単位は画素と呼ばれ、縦と横に並ぶピクセルの数で表現されます。また、輝度はコンピュータで扱う場合には ~ の符号なし整数 (unsigned integer) の 8bit で表現することが一般的です。高さ (Height)、幅 (Width)、奥行き (Channel) の 3 つが画像には存在することがわかりました。こちらは数学的には行列をまとめたものであるため、テンソル (Tensor) に相当します。
画像の特徴抽出
画像がどのような構成要素でできているのかを把握することができました。ここで考えたいことは、これまでは全結合のニューラルネットワークを学びましたが、画像をどのように入力値として全結合のニューラルネットワークに取り入れるかです。
画像はテンソルの形のデータになります。しかし、全結合のニューラルネットワークに取り入れるためには、入力値はベクトルである必要があります。行列でもなく、テンソルの形である画像をどのようにベクトルに変換できるか考えてみましょう。
CNN がどのように画像を取扱うのかについて学ぶ前に、これまでどのように画像をニューラルネットワークの入力値として取り扱っていたのか確認します。
画像を切り取る
1 つ目の方法は単純に画像を横方向に切り取り、並べると行った方法になります。
このように画像を横方向(もしくは縦方向)に切り取り 1 並びにすることによってテンソルの画像をベクトルに変換することが可能です。しかし、この方法には 2 つの欠点があります。
- 画像内の物体の位置を考慮していない : 単純に切り取っているだけのため、画像内の位置は考慮されていません。
- パラメータの数が膨大になる : ニューラルネットワークの入力として取り扱った場合、パラメータの数が膨大になることが考えれます。
2 点目の欠点について確認します。例えば、 の高さ、幅を持つカラー画像を想定します。その場合の画像全体の画素数は、
となります。これを入力値として、次の層のノード数を1000 とした場合、パラメータの数はバイアスも考慮すると、
となり、わずか 2 層の全結合ニューラルネットワークで約 3 千万個のパラメータを調整しなければいけません。
ヒストグラムを取る
次の手段としてヒストグラムを採用することを考えてみましょう。ヒストグラムは画像の輝度の値をカウントします。下の図のように、ヒストグラムではどのような画像サイズに対しても、 ~ の 次元のベクトル(RGB の 3 チャネルを考慮した場合、)となるため、前述のパラメータの数を大幅に減らすことができます。
このように、オリジナルの入力の値を別の値に変換して取り扱いやすくする処理を特徴抽出と呼び、取り出された値を特徴量といいます。
ヒストグラムを用いての特徴量抽出には下記の特徴があります。
- 調整するパラメータ量が比較的少ない(単純に切り取る方法と比較して)
- 画像内の物体の位置の移動に強い(平行移動に強い)
- 画像内の物体の回転に強い
色の輝度のカウントを用いるため、画像内で同じ平行移動や回転に対してもヒストグラムは変化しないため上記のような特徴を挙げる事ができます。しかし、このヒストグラムを用いる方法でもまだ欠点は存在します。
- 画像内の物体の位置を考慮していない
ヒストグラムは輝度をカウントする方法です。例えば人の顔写真を数値として取り扱う場合、肌色が何個あるのか、目の黒色が何個あるのかといった数は特徴として取り扱えますが、目がどこにあるのかといった情報は失われてしまいます。
CNN 以前の画像分類のモデル
実際に特徴抽出を利用した画像処理の流れを解説します。下記の図では例題として、画像を人間か犬か猫の 3 種類に分類したい問題設定を想定します。これまでどのように画像をベクトルに変換し、分類を行っていたのか確認します。下記は手順の一例を示したものになります。
- グレースケール変換の適用(白黒画像に変換を行う処理)
- 画像分類に必要のない背景の除去
- ヒストグラムを用いてベクトル化
- サポートベクトルマシン (SVM) やニューラルネットワークなどの分類器で分類
上記の流れがディープラーニングが登場するまでの一般的な画像分類の流れでした。こちらの方法は今でも使用されています。
ここでこちらの方法の問題点について考えてみましょう。今回紹介した流れでは下記のような点を考慮する必要があります。
- 背景除去の方法はどうするのか?
- どの分類問題設定でもグレースケール変換と背景除去の前処理だけで十分なのか?
- ヒストグラムを用いての特徴量抽出は正しいのか?
この問題点はこれまで画像処理に精通したエンジニアの経験と勘によって対処されてきました。この時点でも属人化の問題や再現性の問題が生じます。更に問題点としてはより複雑な問題設定、例えば A さんと B さんを識別するといった場合には実装が困難もしくは時間を要するといった事が挙げられます。
これまで特徴量抽出の方法、そして古典的な画像分類モデルの問題点について確認しました。この内容を踏まえて CNN がどのようなモデルなのかについて学んでいきます。まず、CNN を理解する上で重要な概念であるフィルタについて学びます。
フィルタとは
エッジ検出の処理を例に、画像処理の実装方法について考えます。画像処理ではフィルタと呼ばれるものがあり、これを画像に対して掛けることによって、画像が変換されます。フィルタはカーネルと呼ぶこともありますので、こちらも覚えておきましょう。それでは、フィルタを掛けるとは具体的にどのような処理かというと、フィルタの値を重みとして、画像の輝度を入力として線形結合の計算を行います。フィルタを掛けた後の値は、上の図を例に出すと、
となります。このフィルタを掛ける処理のことを畳み込み (Convolution) と呼びます。今回はフィルタ(カーネル)のサイズを ksize=3
としています(プログラミングの際にこのような変数名で登場することが多いです)。そして、フィルタの掛ける位置をひとつずらして、また畳み込みの演算を行います。この処理を画像全体に対して適用していきます。このフィルタをずらす幅をストライドと呼び、今回は 1 つずつずらすため、stride=1
となります。
また、もうひとつ考慮すべき点として、畳み込みを行った後の画像サイズです。例えば今回は ksize=3
で stride=1
の場合、畳み込み後の画像が、 になります。元の画像サイズが の場合は畳み込み後のサイズが です。多少小さくなること自体は問題ないのですが、畳み込み後に画像サイズが変わるとプログラミングとして扱いが煩雑になってしまいます。
そこで、画像の周囲を で埋めるパディング (padding) と呼ばれる前処理を行います。これにより、例えば の画像の場合、全周囲に 1 つずつ で埋めるパディングを行うと となり、この画像に対してフィルタサイズが 3 (ksize=3
) の畳み込みを適用すると、畳み込み後に画像が となり、元の画像サイズが保たれます。
このように画像処理では処理したい目的に対してフィルタを定めて、畳み込み演算を行い、画像の変換処理を施します。
エッジ検出
次になぜエッジ検出のフィルタに
という値を使用するかを考えます。なぜこのフィルタを掛けるとエッジを検出することができるのでしょうか。
まず物体のエッジ(輪郭)を捉えるためには、周囲の物体に対して人間がどのように判断しているかを考えてみましょう。おそらく、色合いが大きく異なる点で物体の輪郭を感じると思います。そのため、エッジを検出するためには、輝度の変化量を用いれば良いことが分かります。そして、変化量とは微分を使えば求めることができました。
上図のように、各座標 に対する輝度を表す関数 があり、この勾配 が変化量を表しています。ただし、画像内で座標情報から輝度情報を表現する を求めることは現実的に困難です。一方、前後の座標における輝度 と は求まっているため、2 点を通る直線の傾きを とすると
となり、変化量を近似することができます。
また、上図のように求まったフィルタを画像に対して掛けると、
が得られます。 の定数項を無視すると、上の式より変化量として計算したい値と一致することがわかります。つまり、エッジ検出として紹介していたフィルタの値は適当に決めたものではなく、この変化量を取得したいという意図があって設定されたものであったと気づくことができました。一見、単なる簡単な数値に見えるものがれっきとして数学的な意味を持っています。
例として紹介したエッジ検出のフィルタ(一次微分フィルタ)を実装してみましょう。改めてお伝えしますが、フィルタはカーネルと呼ばれ、サンプルのソースコードでは kernel
という変数名が良く使われます。
# エッジ検出のフィルタの定義(横方向)
kernel = np.array([
[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1]
])
読み込み済みである画像 (img
) に対して、フィルタを適用します。
任意のフィルタを用いた畳み込みの演算には、cv2.filter2D()
メソッドを使います。
cv2.filter2D(src, depth, kernel)
- src: 入力画像
- depth: 出力画像のビット深度(デフォルト: -1)
- kernel: フィルタ(カーネル)を定義
それでは、畳み込みの演算を行います。
# 畳み込み演算
img_conv = cv2.filter2D(img_gray, -1, kernel)
演算結果をプロットしてみましょう。どのような変化が起きているでしょうか。
# 結果を可視化
plt.imshow(img_conv, cmap='gray');
確かに横方向に対して、エッジが検出できていることが直感的に分かると思います。ただまだ分からないので、縦方向のエッジ検出も試して違いを把握してみます。
# エッジ検出のフィルタの定義(縦方向)
kernel = np.array([
[-1, -1, -1],
[0, 0, 0],
[1, 1, 1]
])
# 畳み込み演算
img_conv = cv2.filter2D(img_gray, -1, kernel)
# 結果を可視化
plt.imshow(img_conv, cmap='gray');
先ほどとは異なり、縦方向に輝度の変化量が多い部分が抽出されていることがわかります。特に縦方向の輝度の変化が強い目元を確認すると横方向のエッジ検出のフィルタとの違いが確認することができます。
このようにフィルタ(カーネル)を使って他にはノイズ除去をしたり、画像をぼかしたりすることができます。その他の代表的なフィルタとして、ラプラシアンフィルタと平滑化フィルタなどがありますので、興味のある方は調べて実装してみてください。
畳み込みニューラルネットワーク
エッジ検出のフィルタの値はわかったのですが、犬と猫を判別するためのフィルタの値はいくらでしょうか。また、背景を除去することができるフィルタの値はいくらでしょうか。このように考えると、一見便利そうなフィルタですが、そのフィルタの値を決定することができなければ使うことができず、このフィルタの値を決めることが難しいタスクであるということがわかります。
そのため、画像処理という学問が体系だっていましたがブレイクスルーを起こすことができませんでした。しかし、近年画像処理の領域でブレイクスルーが起きています。それが、畳み込みニューラルネットワークです。
従来の画像解析が上の流れだとすると、これから紹介する畳み込みニューラルネットワークでは、この畳み込みと全結合ニューラルネットワークの働きを一体化させ、特徴抽出と分類器を学習するプロセスをすべてニューラルネットワークの中に包括しました。これにより、これまで経験と勘によって行われていた前工程を自動化することができました。
畳み込みニューラルネットワークには、大きく以下の 3 つの処理を含んでいます。
- 畳み込み (Convolution)
- プーリング (Pooling)
- 全結合層 (Fully Connected layer)
順を追って確認していきます。
畳み込み (Convolution)
畳み込みニューラルネットワークの核となるのが 畳み込み (Convolution) になります。これは前述したフィルタの計算を行う部分であり、入力情報の特徴を捉えます。エッジ検出ではフィルタの値がすべて決まっていましたが、畳み込みではすべて学習すべきパラメータとなっており、フィルタのサイズ、ストライドの値、パディングの有無がハイパーパラメータとなってます。また、畳み込み後に出力される値は特徴マップ (Feature map) と呼ばれます。
プーリング (Pooling)
畳み込まれた特徴マップを縮小させる処理をプーリング (Pooling) と呼びます。プーリング処理にはいくつか種類があり、
- MaxPooling
- AveragePooling
- GlobalAveragePooling
MaxPooling が最もよく使用されるので、最初はこちらを覚えておきましょう。処理内容を下図で表します。
MaxPooling では、プーリングサイズ内で最も大きな値を取り、画像の縮小を行います。
プーリングサイズ処理の種類はプーリングサイズ内で最大の値か平均の値なのか、どのような代表的な値を取るかで分かれています。前述した畳み込み処理とプーリング処理は続けて行うことが一般的であり、世界的な画像コンペティションでは複数回続けて処理することでモデルの性能を高めています。
全結合層 (Fully Connected layer)
畳み込みとプーリングを複数回行った後、最終的に出力された複数のチャネルからなる画像の値を 1 列に並べ、ニューラルネットワークの入力層の値として用います。 全結合層の流れに関しては前章までで取り扱ったニューラルネットワークと同様になります。
本章では、基礎的な画像処理や畳み込みニューラルネットワークについて学びました。まとめると、畳み込みニューラルネットワークでは従来から使われてきた画像処理のフィルタから着想を得ており、人間と犬と猫を判別できるようなフィルタを経験と勘で求めることが難しいのであれば、これも一種のパラメータとして学習させれば良いと考えました。
その畳み込みニューラルネットワークは、今ではディープラーニングの最も重要で効果的なアーキテクチャの 1 つとなっています。次章では、その畳み込みニューラルネットワークの実装方法をお伝えします。