CCCMKホールディングス TECH Labの Tech Blog

TECH Labスタッフによる格闘記録やマーケティング界隈についての記事など

ブログタイトル

一般物体検知アルゴリズム Single Shot MultiBox Detector(SSD)をTensorflow v2で動くようにしつつ理解する

こんにちは、技術開発の三浦です。今回は一般物体検知アルゴリズム Single Shot MultiBox Detector(SSD)について調べたのでどのような技術なのか論文や実装されたソースコードをもとに紹介させていただきます。

一般物体検知(Object Detection)とは

画像認識のタスクの一種で画像の中に何がどこにあるのかを検知するものです。位置と物体の種類を両方推計する必要があり、難しいタスクです。一般物体検知は古くから研究されている分野ですが、アルゴリズムの一部に深層学習のアーキテクチャが組み込まれるようになり、その精度は大きく向上しています。一般物体検知を使えば、たとえばお店の中に設置したカメラの画像から今店内にお客さんがどれだけいるのか、商品がどれだけ陳列されているのかを推計し、お客さんの人数と比較して陳列されている商品が少ない場合、アラートを出すようなシステムを作ることが出来ます。

Single Shot MultiBox Detector(SSD)

今回紹介するSingle Shot MultiBox Detector(SSD)は一般物体検知のためのアルゴリズムです。アルゴリズムの大部分に深層学習のネットワークが使われています。そのネットワークも比較的シンプルな構造になっているため高速な推論を実現しています。実装されたソースコードや学習済みのモデルなどはGithubなどに公開されており、それらをクローンすれば比較的短時間でSSDを動かすことができます。たとえばTensorflowで実装されたこちらのコードを使ってSSDを動かした経験があります。

github.com

動かした経験はあるものの、SSDが一体どういうアルゴリズムなのかについてはこれまで理解していませんでした。理解しようとしたものの途中で挫折してしまったのです・・・。しかしながらオセロAIを実装した経験からなんとなく深層学習の世界を身近に感じることが出来るようになったので、2020年のGWを使って再度理解しようとチャレンジしてみました。

理解のために

論文だけだと具体的なイメージがつかめず、なかなか理解が出来ないので実装されたコードを写経しながら読み解いてみることにしました。こちらのKerasで実装されたコードが割とわかりやすい印象を受けたのでこちらを写経の対象にしました。

github.com

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の要素はより大きな格子の情報が詰まっています。論文に掲載されているこちらの絵がわかりやすいです。

f:id:miu4930:20200510074652p:plain

[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を使用することで、様々なサイズの物体検知に対応できるようになります。

f:id:miu4930:20200510082834p:plain
SSDのネットワークアーキテクチャ

[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_weightsgammaを設定する箇所

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

ちなみにPriorBoxcallの処理

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でテストデータをいじりながら理解しました。

f:id:miu4930:20200510083148p:plain
左がVSCodeで右がColabo

ssd.py

内容

これはSSDのネットワークを定義します。

Tensorflow v2対応

import部分

SSD_layers.pyと同様tensorflow.kerasからimportするように変更します。またConvolution2Dは使えるものの公式ドキュメントから消えているのでConv2Dに変更しました。またAtrousConvolution2Dは現在のKerasにはなく、代わりにConv2Dのパラメータdilation_rateを指定することで実現することが出来ます。AtrousConvolutionは以下のようなカーネルで畳み込む層とのことです。

f:id:miu4930:20200510083624p:plain

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ではplaceholderSessionがなくなったようです。

www.tensorflow.org

どうしたらいいのかな・・・と途方にくれたのですが、シンプルに考えれば良いみたいです。

変更前

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_threshtop_ksetterについて、__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の論文のこちらの式に対応しています。

f:id:miu4930:20200510083908p:plain

[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__で対応したplaceholderSessionがなくなってしまった問題をこれで解消しました。

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部分

kerastensorflow.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で実際にオリジナルの一般物体検知を行う手順を紹介したいと思います!

f:id:miu4930:20200510085104p:plain
オリジナルの画像で動くかテストしてみました。

追記

Kerasの作者の5月9日のtweetにKerasの公式サイトが新しくなったとありました。Code examplesなどのKerasのインポートの部分が以下のようにtensorflowのものを使用する内容になったようです。

import tensorflow as tf
from tensorflow import keras

今後はtensorflow.kerasに一本化されていくのでしょうか。

f:id:miu4930:20200513122234p:plain
Keras公式サイトのインストール手順もTensorflow 2.0をインストールする内容に

https://keras.io/about/#installation-amp-compatibility