こんにちは、技術開発の三浦です。今回は一般物体検知アルゴリズム Single Shot MultiBox Detector(SSD)について調べたのでどのような技術なのか論文や実装されたソースコードをもとに紹介させていただきます。
一般物体検知(Object Detection)とは
画像認識のタスクの一種で画像の中に何がどこにあるのかを検知するものです。位置と物体の種類を両方推計する必要があり、難しいタスクです。一般物体検知は古くから研究されている分野ですが、アルゴリズムの一部に深層学習のアーキテクチャが組み込まれるようになり、その精度は大きく向上しています。一般物体検知を使えば、たとえばお店の中に設置したカメラの画像から今店内にお客さんがどれだけいるのか、商品がどれだけ陳列されているのかを推計し、お客さんの人数と比較して陳列されている商品が少ない場合、アラートを出すようなシステムを作ることが出来ます。
Single Shot MultiBox Detector(SSD)
今回紹介するSingle Shot MultiBox Detector(SSD)は一般物体検知のためのアルゴリズムです。アルゴリズムの大部分に深層学習のネットワークが使われています。そのネットワークも比較的シンプルな構造になっているため高速な推論を実現しています。実装されたソースコードや学習済みのモデルなどはGithubなどに公開されており、それらをクローンすれば比較的短時間でSSDを動かすことができます。たとえばTensorflowで実装されたこちらのコードを使ってSSDを動かした経験があります。
動かした経験はあるものの、SSDが一体どういうアルゴリズムなのかについてはこれまで理解していませんでした。理解しようとしたものの途中で挫折してしまったのです・・・。しかしながらオセロAIを実装した経験からなんとなく深層学習の世界を身近に感じることが出来るようになったので、2020年のGWを使って再度理解しようとチャレンジしてみました。
理解のために
論文だけだと具体的なイメージがつかめず、なかなか理解が出来ないので実装されたコードを写経しながら読み解いてみることにしました。こちらのKerasで実装されたコードが割とわかりやすい印象を受けたのでこちらを写経の対象にしました。
Keras v1.2.2, Tensorflow v1.0.0で動作するようですが、Tensorflow v2系では動作しません(Tensorflowはv2になってからかなり仕様が変わりました。)。またTensorflowにKerasが同梱されたこともあり、こちらのコードをTensorflow v2.xで動くように自力で書き換えてみることにチャレンジしました。以降は各モジュールについて、(自分が理解した範囲ですが)何を行っているのか、Tensorflow v2.xで動作させるために行った修正などを紹介させていただきたいと思います。
SSDの大まかな理解
まず最初にSSDの全体像をざっくり理解します。画像を畳み込みニューラルネットワーク(CNN)に入力すると畳み込み層を通過するたびに画像のローカルな特徴が抽出されたfeature mapが出力されます。このfeature mapの各要素(チャネルの長さのベクトル)には元画像を格子状に分割した時に各格子の特徴が詰まっていると言えます。深い層で出力されるfeature mapの要素はより大きな格子の情報が詰まっています。論文に掲載されているこちらの絵がわかりやすいです。
[1512.02325] SSD: Single Shot MultiBox Detector
あるfeature mapとそれに対応する格子を元の画像に描写します。各格子の中心に対し、異なる縦横比(アスペクト比)のprior boxを出力します。各prior boxと元画像の認識させたい物体の位置がどれだけ被っているのかを計算し、あるしきい値を超えたprior boxが推論のためにアサインされます。アサインされた各prior boxに対して認識する物体とprior boxのズレ(loc)とそれが何かを表す信頼スコア(conf)を出力し、それを損失関数に使用し最適化を図っていくことでどこに何が写っているのかを検知できるようになります。locの最適化は回帰問題、confの最適化は分類問題なので、SSDは回帰と分類を同時に解きます。各feature mapで異なるサイズのprior boxを使用することで、様々なサイズの物体検知に対応できるようになります。
[1512.02325] SSD: Single Shot MultiBox Detector
コードの理解
以降実際のコードを見ていきます。
ssd_layers.py
内容
これはSSDのネットワークに使用するカスタムレイヤを定義しています。Normalize
がカスタマイズされたl2正規化層でPriorBox
がfeature mapと元画像のサイズに合わせたprior box群を出力する層です。
Tensorflow v2対応
import部分
Tensorflowに同梱されたKerasを使いたいのでimportの部分
from keras.engine.topology import InputSpec
を
from tensorflow.keras.layers import InputSpec
のように変更しました。またLayer
のimport
from keras.engine.topology import Layer
を
from tensorflow.keras.layers import Layer
に変更しました。
Normalize
Normalize
する方向axis
を設定する箇所
if K.image_dim_ordering() == 'tf': self.axis = 3
を
if K.image_data_format() == 'channels_last': self.axis = 3
に変更。さらにtrainable_weights
にgamma
を設定する箇所
self.trainable_weights = [self.gamma]
を
self.trainable_weights.append(self.gamma)
に変更しました。またモデルを出力する際に__init__
に記載された変数がconfig
にないといったエラーが出たため以下を追加しました。
def get_config(self): config = super().get_config().copy() config.update({ 'scale':self.scale, }) return config
PriorBox
Normalize
と同様にK.image_dim_ordering()
の部分を変更します。また、以下の部分
if hasattr(x, '_keras_shape'): input_shape = x._keras_shape elif hasattr(K, 'int_shape'): input_shape = K.int_shape(x)
を
if hasattr(x, '_shape_val'): input_shape = x._shape_val elif hasattr(K, 'int_shape'): input_shape = K.int_shape(x)
に変更しました。(が、これは不要かもしれません)
prior_boxes_tensor
を作る箇所
prior_boxes_tensor = K.expand_dims(K.variable(prior_boxes), 0)
はこのままだとエラーになるので
prior_boxes_tensor = tf.expand_dims(prior_boxes,0)
に変更しました。Normalize
と同様に以下を追加します。
def get_config(self): config = super().get_config().copy() config.update({ 'img_size':self.img_size, 'min_size':self.min_size, 'max_size':self.max_size, 'aspect_ratios':self.aspect_ratios, 'variances':self.variances, 'clip':self.clip, }) return config
ちなみにPriorBox
のcall
の処理
linx = np.linspace(0.5 * step_x, img_width - 0.5 * step_x, layer_width)
は0.5 * step_x
からimg_width - 0.5 * step_x
の範囲でlayer_width
個の等差数列を生成します。layar_width
がfeature mapのサイズなので、格子の中心点を求める処理ですね。
あと
prior_boxes[:, ::4] -= box_widths prior_boxes[:, 1::4] -= box_heights prior_boxes[:, 2::4] += box_widths prior_boxes[:, 3::4] += box_heights prior_boxes[:, ::2] /= img_width prior_boxes[:, 1::2] /= img_height
::4
のスライシングは0からインデックス4つ飛び飛びで要素にアクセスします。ちなみに::-1
は配列を逆順にします。このあたりは毎回忘れるので備忘録・・・。行列計算はイメージがつかみにくいことが多いのでGoogleのColaboratoryでテストデータをいじりながら理解しました。
ssd.py
内容
これはSSDのネットワークを定義します。
Tensorflow v2対応
import部分
SSD_layers.pyと同様tensorflow.keras
からimportするように変更します。またConvolution2D
は使えるものの公式ドキュメントから消えているのでConv2D
に変更しました。またAtrousConvolution2D
は現在のKerasにはなく、代わりにConv2D
のパラメータdilation_rate
を指定することで実現することが出来ます。AtrousConvolutionは以下のようなカーネルで畳み込む層とのことです。
A 2017 Guide to Semantic Segmentation with Deep Learning
また層を結合するmerge
もなくなっており、Concatenate
を使用します。
以下が変更前
import keras.backend as K from keras.layers import Activation from keras.layers import AtrousConvolution2D from keras.layers import Convolution2D from keras.layers import Dense from keras.layers import Flatten from keras.layers import GlobalAveragePooling2D from keras.layers import Input from keras.layers import MaxPooling2D from keras.layers import merge from keras.layers import Reshape from keras.layers import ZeroPadding2D from keras.models import Model
以下が変更後です。
import tensorflow.keras.backend as K from tensorflow.keras.layers import Activation from tensorflow.keras.layers import Conv2D from tensorflow.keras.layers import Dense from tensorflow.keras.layers import Flatten from tensorflow.keras.layers import GlobalAveragePooling2D from tensorflow.keras.layers import Input from tensorflow.keras.layers import MaxPooling2D from tensorflow.keras.layers import Concatenate from tensorflow.keras.layers import Reshape from tensorflow.keras.layers import ZeroPadding2D from tensorflow.keras.models import Model
畳み込み層
カーネルのサイズの指定やパディングの指定の方法が変わっています。
変更前
net['conv1_1'] = Convolution2D(64, 3, 3, activation='relu', border_mode='same', name='conv1_1')(net['input'])
変更後
net['conv1_1'] = Conv2D(64, (3, 3), activation='relu', padding='same', name='conv1_1')(net['input'])
パッディングの指定の変更はプーリング層も同様にborder_mode
からpadding
に変更します。
fc6
fc6
はAtrousConvolutionです。前述したようにAtrousConvolution2D
がなくなったので、以下のように変更します。
変更前
net['fc6'] = AtrousConvolution2D(1024, 3, 3, atrous_rate=(6, 6), activation='relu', border_mode='same', name='fc6')(net['pool5'])
変更後
net['fc6'] = Conv2D(1024, (3, 3), dilation_rate=(6, 6), activation='relu', padding='same', name='fc6')(net['pool5'])
畳み込み層のストライド
conv6_2
以降畳み込み層でストライドを指定する箇所があります。subsample
ではなくstrides
で指定します。
変更前
net['conv6_2'] = Convolution2D(512, 3, 3, subsample=(2, 2), activation='relu', border_mode='same', name='conv6_2')(net['conv6_1'])
変更後
net['conv6_2'] = Conv2D(512, (3, 3), strides=(2, 2), activation='relu', padding='same', name='conv6_2')(net['conv6_1'])
層の結合
各feature mapからの出力を結合する箇所は以下のようになります。
変更前
net['mbox_loc'] = merge([net['conv4_3_norm_mbox_loc_flat'], net['fc7_mbox_loc_flat'], net['conv6_2_mbox_loc_flat'], net['conv7_2_mbox_loc_flat'], net['conv8_2_mbox_loc_flat'], net['pool6_mbox_loc_flat']], mode='concat', concat_axis=1, name='mbox_loc')
変更後
net['mbox_loc'] = Concatenate(axis=1,name='mbox_loc')([ net['conv4_3_norm_mbox_loc_flat'], net['fc7_mbox_loc_flat'], net['conv6_2_mbox_loc_flat'], net['conv7_2_mbox_loc_flat'], net['conv8_2_mbox_loc_flat'], net['pool6_mbox_loc_flat'] ])
その他
これまでと同様K.image_dim_ordering
を変更したりしました。またたくさんの層があるので値の入力ミスなどに気をつける必要があります。特にconv7_2
はパディングが'valid'
になっている点も注意します。
ssd_utlils.py
内容
物体の位置の情報から学習用のデータを生成したり、推計値をデコードして表示用のデータに変換する機能を実装します。SSDの重要な部分だと思います。
Tensorflow v2対応
__init__
Tensorflow v2ではplaceholder
やSession
がなくなったようです。
どうしたらいいのかな・・・と途方にくれたのですが、シンプルに考えれば良いみたいです。
変更前
self.boxes = tf.placeholder(dtype='float32', shape=(None, 4)) self.scores = tf.placeholder(dtype='float32', shape=(None,)) self.nms = tf.image.non_max_suppression(self.boxes, self.scores, self._top_k, iou_threshold=self._nms_thresh) self.sess = tf.Session(config=tf.ConfigProto(device_count={'GPU': 0}))
変更後
#self.boxes = tf.placeholder(dtype='float32', shape=(None, 4)) #self.scores = tf.placeholder(dtype='float32', shape=(None,)) self.boxes = None self.scores = None #self.nms = tf.image.non_max_suppression(self.boxes, self.scores, # self._top_k, # iou_threshold=self._nms_thresh) #self.sess = tf.Session(config=tf.ConfigProto(device_count={'GPU': 0}))
setter
nms_thresh
やtop_k
のsetter
について、__init__
の変更により以下のように変更します。
変更前
@nms_thresh.setter def nms_thresh(self, value): self._nms_thresh = value self.nms = tf.image.non_max_suppression(self.boxes, self.scores, self._top_k, iou_threshold=self._nms_thresh)
変更後
@nms_thresh.setter def nms_thresh(self, value): self._nms_thresh = value
iou
特に変更箇所はないのですが、与えられた領域とprior box群との重なり具合を計算するためのものです。
encode_box
こちらも変更箇所はないです。ここは学習用のデータのうち位置に関する損失のための計算をしています。
encoded_box[:, :2][assign_mask] = box_center - assigned_priors_center encoded_box[:, :2][assign_mask] /= assigned_priors_wh encoded_box[:, :2][assign_mask] /= assigned_priors[:, -4:-2] encoded_box[:, 2:4][assign_mask] = np.log(box_wh / assigned_priors_wh) encoded_box[:, 2:4][assign_mask] /= assigned_priors[:, -2:]
はSSDの論文のこちらの式に対応しています。
[1512.02325] SSD: Single Shot MultiBox Detector
assign_boxes
物体の位置とiouが高いprior boxを損失計算用にアサインする処理を行っています。
decode_boxes
prior boxに対して推論された位置のズレを適用し、物体の領域にシフトさせます。
detection_out
最後に元画像に適用できる形式で出力します。物体の予測スコアがある一定のしきい値を超えているものだけを物体候補として出力します。それでも多数のpror boxからの出力は領域が重複しているものが多く無駄なのでNon-Maxima Suppression (nms)という処理をかけて同じ物体を予測している領域間でiouを測定、ある一定以上のiouを示す領域については予測スコアが高い方のみを残す、というフィルタをかけます。コードの変更はそのnms
の処理の部分になります。
変更前(219行付近)
if len(c_confs[c_confs_m]) > 0: boxes_to_process = decode_bbox[c_confs_m] confs_to_process = c_confs[c_confs_m] feed_dict = {self.boxes: boxes_to_process, self.scores: confs_to_process} idx = self.sess.run(self.nms, feed_dict=feed_dict) good_boxes = boxes_to_process[idx]
変更後
if len(c_confs[c_confs_m]) > 0: boxes_to_process = decode_bbox[c_confs_m] confs_to_process = c_confs[c_confs_m] self.boxes = boxes_to_process self.scores = confs_to_process idx = tf.image.non_max_suppression(self.boxes, self.scores, self._top_k, iou_threshold=self._nms_thresh) idx = idx.numpy() good_boxes = boxes_to_process[idx]
__init__
で対応したplaceholder
やSession
がなくなってしまった問題をこれで解消しました。
ssd_training.py
内容
領域のズレの損失l1_smooth_loss
と分類の損失softmax_loss
を定義し、最後にそれらを組み合わせた損失を計算します。
Tensorflow v2対応
_softmax_loss
対数計算の仕方が変更されているようです。
変更前
softmax_loss = -tf.reduce_sum(y_true * tf.log(y_pred),axis=-1)
変更後
softmax_loss = -tf.reduce_sum(y_true * tf.math.log(y_pred),axis=-1)
compute_loss
型の変換の仕方が変更されています。
変更前
has_min = tf.to_float(tf.reduce_any(pos_num_neg_mask))
変更後
has_min = tf.cast(tf.reduce_any(pos_num_neg_mask),tf.float32)
tf.to_xxx
がなくなったようです。compute_loss
には上記のような型変換を行う箇所がいくつかあるため、同様に変更を行いました。
SSD_training.ipynb
内容
これまで作ってきたモジュールを組み合わせてSSDの学習及び推論を行うサンプルのnotebookです。
Tensorflow v2対応
ここはTensorflowの対応、というよりもScipy対応がメインです。scipy.misc.imread, scipy.misc.imresize
が1.3.0で削除されたそうなので画像データの取扱部分を変更します。
import部分
keras
をtensorflow.keras
に変更します。scipy.misc.imread
については公式のドキュメントに従いimageio.imread
で対応、scipy.misc.imresize
については同じく公式ドキュメントに従いPillow
で対応します。以下を追加でインポートしました。
from imageio import imread from PIL import Image
Generator
まずimg
を読み込む箇所
img = imread(img_path).astype('float32')
はコードの変更はないものの変更前はscipy.misc.imread
が使われているのに対し変更後はimageio.imread
が使用されます。あとはrandom_sized_crop
後のimg
のリサイジングの処理を
img = imresize(img, self.image_size).astype('float32')
から
img = np.array(Image.fromarray((img * 255).astype(np.uint8)).resize(self.image_size)).astype('float32')
に変更しました。
以上がTensorflow v2で動作させるために行った変更です。
感想
コードを見つつ論文を見つつ解説サイトを見つつ理解を進めてみました。実際やってみると、コードの写経だけでもタイプミスなどしてしまいかなり大変でした。特にコードとしては問題はないけれど学習のパラメータ部分で思わぬタイプミスをしてしまい、どうしてもSSDの学習が進まない・・・といった問題も起こりました。苦労はしたのですが、やっぱりいい勉強になったと思うしコーディングの力もついたように感じます。一般物体検知はSSD以外のアルゴリズムもあるので、今度は別のアルゴリズムの実装にもチャレンジしてみたいと思いました!次回は今回作ったSSDで実際にオリジナルの一般物体検知を行う手順を紹介したいと思います!
追記
Kerasの作者の5月9日のtweetにKerasの公式サイトが新しくなったとありました。Code examplesなどのKerasのインポートの部分が以下のようにtensorflowのものを使用する内容になったようです。
import tensorflow as tf from tensorflow import keras
今後はtensorflow.kerasに一本化されていくのでしょうか。