HOG特徴量とSVMを使った自動車の検出

あけましておめでとうございます。

去年の10月に研究室に配属されてからあれよあれよという間に年を越してしまいました。

課題研究の関係で論文を色々漁ったのと去年の夏のインターンシップで一般物体認識をやったのでここらで一旦まとめる為にエントリを残しておきます。本エントリは初心者の方が対象であるのと、私自身まだ機械学習を始めてから日が浅いので間違いや危ない表現も多いかと思います。

今回は自動車の検出をやろうと思います。巷ではDeepLearningが流行ってますがそんなことは気にせず、少し古い手法ですがHOG+SVMでやっていきます。

HOG特徴量 (Histograms of Oriented Gradients)

HOG特徴量とは局所領域内の勾配方向ごとの勾配強度を計算し、ヒストグラムで表したものです。すごく簡単に言うと画像中の輝度の変化の境界線を取り出す事が出来るというものです。*1

f:id:cool_on:20160122175516p:plain

f:id:cool_on:20160122175521p:plain

上の二枚の画像は実際に自動車の画像からHOG特徴量を取り出したもので、一枚目の画像からHOG特徴量を計算し、二枚目に可視化しています。うっすらとですが二枚目に車体の輪郭が確認できるかと思います。また車体の塗装の境界に強く反応していることがわかります。

計算について解説すると書く量が膨れ上がるので、HOG特徴量についてはここで切り上げます。大体のイメージが掴めればOKです。僕がお世話になったHOG特徴量による人検出の論文のURLを載せておきますので気になった方はどうぞ
https://lear.inrialpes.fr/people/triggs/pubs/Dalal-cvpr05.pdf


ちなみにアニメ絵のような各閉領域内の輝度の変化が少ない画像の場合は輪郭線を綺麗に抽出することが出来るので、アニメ顔の検出・認識やアスキーアートの自動生成等に使えるような気がします。*2

f:id:cool_on:20160122182347p:plain f:id:cool_on:20160122182350p:plain


SVM (Support Vector Machine)

SVMは入力を正解か不正解かに分類する分類器です。大量のデータとラベルの組から「こういうデータには正解ラベルが付く」「このデータには不正解ラベルが」という様なデータとラベルの関係を学習し、正解と不正解の境界を計算するのが仕事です。未知の入力データをこの境界線に照らし合わせることで、そのデータが正解か不正解か判断することが出来ます。

では実際にどのようにして境界線を決定するのかを見ていきましょう。
次のような学習用の入力をx、出力をyでプロットした平面を考えてみます。

f:id:cool_on:20160122223221p:plain

青は正解のラベルが貼られたデータ群、赤は不正解のラベルが貼られたデータ群とします。
次にこの二つのグループを隔てる境界線を引いてみましょう。引き方はいくらでも存在するのですが、大抵の人はこう線を引くのではないでしょうか。

f:id:cool_on:20160122224029p:plain

しかし、先にも述べたように境界線の引き方はいくらでもあり、次のように引くことも可能です。

f:id:cool_on:20160122230725p:plain

どちらの境界線の引き方が良いのでしょうか?
後者の境界線の引き方の場合、正解グループの領域が不正解グループの領域に対して極端に狭く、本来正解グループに分類されるはずのデータを誤って不正解グループに分類してしまう可能性が高いです。この例の様に少ない学習データから境界を決定する場合、なるべく二つのグループの領域の広さが同じくらいになるように決定したほうが分類に都合がいいのです。

よって前者の境界線の引き方の方がもっともらしいです。
あくまでもっともらしいというだけです。基本的にSVMは、より分類に適した境界を探すことは出来ますが、完全に正解と不正解を分類する真の境界を見つけることはほぼ出来ません。

f:id:cool_on:20160122235014p:plain

正解のグループ、不正解のグループの領域の広さをなるべく同じにするために「マージン最大化」という方法があります。マージン最大化とは、「各グループからもう一方のグループに一番近いデータ」から境界線までのユークリッド距離がそれぞれ最も大きくなる様な(上の図では各々の矢印の長さが一番長くなる様な)線を境界とする手法です。詳細は割愛しますが気になった方はGoogleで検索すると山のようにヒットするのでそちらを御覧ください。

自動車の検出

さて、HOGとSVMについては軽くですが説明をしたのでそろそろコードを示しつつ実際に検出を行っていきます。

学習データ

まずは学習用のデータを用意しましょう。今回はUIUC Image Database for Car Detectionのデータを使って横向きの自動車の検出を行います。画面中央のDownloadリンクを押すとダウンロードすることが出来ます。
ダウンロードしたファイルを解凍すると中に「TrainImages」というフォルダがあり、この中に学習用の100×40サイズの画像がポジティブ550枚、ネガティブ500枚入っています。それぞれポジティブ用のフォルダ、ネガティブ用のフォルダに分類しておくといいでしょう。

使用言語

言語はPythonを使います。理由は機械学習ライブラリが豊富だからです。C++を使ったほうが速いのですがこの程度の学習なら大して時間もかからないので。機械学習に挑戦してみようと思う方には学習コストの小さいPythonをおすすめします。

ライブラリは

  • scikit-image
  • scikit-learn
  • matplotlib

を使います。ライブラリの導入方法はGoogleで検索すれば見つけることが出来るので割愛します。

特徴量の抽出

まずは学習データからHOG特徴量の抽出を行います。計算はライブラリに任せればいいので画像ファイルを一枚一枚関数に渡すだけの作業になります。

# coding: utf-8

import os
from skimage import data, color, exposure
from skimage.feature import hog
import matplotlib.image as mpimg
import matplotlib.pyplot as plt
import numpy as np

#パスは適宜変更してください
pos_img_dir = './CarData/PosImages/'
pos_img_files = os.listdir(pos_img_dir)

neg_img_dir = './CarData/NegImages/'
neg_img_files = os.listdir(neg_img_dir)

PERSON_WIDTH = 100
PERSON_HEIGHT = 40
leftop = [0,0]
rightbottom =  [0+PERSON_WIDTH,0+PERSON_HEIGHT]

X = []
y = []

## ポジティブ画像からのHOG特徴量の取り出し
print 'start loading ' + str(len(pos_img_files)) + ' positive files'
for pos_img_file in pos_img_files:
    pos_filepath = pos_img_dir + pos_img_file
    pos_img = data.imread(pos_filepath,as_grey=True)
    pos_roi = pos_img[leftop[1]:rightbottom[1],leftop[0]:rightbottom[0]]
    fd = hog(pos_roi, orientations=9, pixels_per_cell=(6,6),cells_per_block=(3,3), visualise=False)
    X.append(fd)
    y.append(1)

## ネガティブ画像からのHOG特徴量の取り出し
print 'start loading ' + str(len(neg_img_files)) + ' negative files'
for neg_img_file in neg_img_files:
    neg_filepath = neg_img_dir + neg_img_file
    neg_img = data.imread(neg_filepath,as_grey=True)
    neg_roi = neg_img[leftop[1]:rightbottom[1],leftop[0]:rightbottom[0]]
    fd = hog(neg_roi, orientations=9, pixels_per_cell=(6,6),cells_per_block=(3,3), visualise=False)
    X.append(fd)
    y.append(0)
 
## リストをnp.array型に変換
X = np.array(X)
y = np.array(y)
print X.shape
print y.shape
print 'X', X
print 'y', y

# 特徴量の書き出し
np.savetxt("HOG_car_data.csv", X, fmt="%f", delimiter=",")
np.savetxt("HOG_car_target.csv", y, fmt="%.0f", delimiter=",")

ポジティブのデータには1のラベルを、ネガティブのデータには0のラベルを付けます。抽出した特徴量は後でSVMに投げるのでcsvで保存しておきましょう。HOG特徴量のパラメータについては先に挙げた論文中で最も精度の高かったものを使っています
ちなみに上のコードを実行すると次のように特徴量の次元と値を標準出力に出力します。

start loading 550 positive files
start loading 500 negative files
(1050, 1584)
(1050,)
X [[  4.66931193e-02   2.93166990e-03   6.50107761e-03 ...,   4.59548444e-03
   -1.28272630e-18   6.41363152e-19]
 [  7.79781310e-02   2.36132992e-02   1.42864679e-02 ...,   1.22285896e-02
    0.00000000e+00   8.18764909e-18]
 [  6.16024277e-02   2.26079295e-02   7.87498635e-03 ...,   8.08761157e-03
    3.55559781e-03   1.96605842e-18]
 ...,
 [  5.01269811e-02   1.25408472e-02   1.61192195e-02 ...,   1.68446925e-02
    2.79458804e-02   2.76833738e-02]
 [  2.70712745e-03   1.79288310e-04   5.06187105e-03 ...,   3.01869974e-02
    5.41379078e-02   7.29886892e-02]
 [  6.15938638e-02   5.49823009e-03   6.60191404e-03 ...,   2.36499833e-02
    2.97579239e-03   3.48266244e-18]]
y [1 1 1 ..., 0 0 0]

学習

いよいよ特徴量とラベルの関係を学習させます。
具体的には次のコードのようにして先ほど取り出した特徴量をSVMに投げ込みます。

# coding: utf-8

import numpy as np
from sklearn import svm
from sklearn.externals import joblib
from sklearn import cross_validation
from sklearn.cross_validation import train_test_split
from sklearn.grid_search import GridSearchCV

# 特徴量の読み込み
X = np.loadtxt("HOG_car_data.csv", delimiter=",")
y = np.loadtxt("HOG_car_target.csv", delimiter=",")
print X.shape
print y.shape
print 'X', X
print 'y', y

# 線形SVMの学習パラメータを格子点探索で求める
tuned_parameters = [{'C': [0.1, 0.5, 1.0, 5.0, 10.0, 50.0, 100.0]}]

print 'start Grid Search'
gscv = GridSearchCV(svm.LinearSVC(), tuned_parameters, cv=5)
gscv.fit(X, y)
svm_best = gscv.best_estimator_

print 'searched result of  C =', svm_best.C

# 最適(?)なパラメータを用いたSVMの再学習
print 'start re-learning SVM with best parameter set.'
svm_best.fit(X, y)

# 学習結果の保存
print 'finish learning SVM with Grid-search.'
joblib.dump(svm_best, 'Best_car_detector.pkl', compress=9)

上では述べませんでしたがSVMにはいくつかパラメータが存在し、これを入力するデータの種類によって適切にチューニングすることで精度を出すことが出来るようになります。今回はそのうちの一つ、Cパラメータを調整してみます。本来、SVMのチューニング方法であるグリッドサーチは膨大な範囲の値を一つ一つ入力して一番精度のいいものを選ぶのですが、そうするとかなりの時間がかかるので面倒です。今回は精度よりもパッパと調整してとりあえず分類器が動くかどうかを優先したのでCの値を0.1, 0.5, 1.0, 5.0, 10.0, 50.0, 100.0に限定してそれぞれSVMに掛け、一番精度のいいCパラメータを使うことにしました。パラメータについて詳しくは書きませんのでGoogleで調べるなりなんなりと。

上のコードを実行すると次のような出力と共に、最適なCパラメータでチューニングされたSVMを使った学習結果のファイルが出力されます。

(1050, 1584)
(1050,)
X [[ 0.046693  0.002932  0.006501 ...,  0.004595 -0.        0.      ]
 [ 0.077978  0.023613  0.014286 ...,  0.012229  0.        0.      ]
 [ 0.061602  0.022608  0.007875 ...,  0.008088  0.003556  0.      ]
 ...,
 [ 0.050127  0.012541  0.016119 ...,  0.016845  0.027946  0.027683]
 [ 0.002707  0.000179  0.005062 ...,  0.030187  0.054138  0.072989]
 [ 0.061594  0.005498  0.006602 ...,  0.02365   0.002976  0.      ]]
y [ 1.  1.  1. ...,  0.  0.  0.]
start Grid Search
searched result of  C = 0.5
start re-learning SVM with best parameter set.
finish learning SVM with Grid-search.

検出

さあ、いよいよ検出です。今回はスライディングウィンドウという方法を使って、画像を全部なめていきます。

# coding: utf-8

# # 画像の部分領域に検出器を適用
# 画像のすべての部分領域に検出器を適用して,その結果の歩行者らしさを表示

import os
import numpy as np
from skimage import data, color, exposure
from skimage.feature import hog
from skimage.transform import resize
import matplotlib.image as mpimg
import matplotlib.pyplot as plt
from sklearn import svm
from sklearn.externals import joblib
import math

# SVMの学習結果を読み込む
print 'start loading SVM.'
detector = joblib.load('Best_car_detector.pkl')
print 'finish loading SVM'

# 検出器の大きさの指定
PERSON_WIDTH = 100
PERSON_HEIGHT = 40

# 検出対象画像の指定
test_img_path = './CarData/TestImages/test-3.pgm'
test_img = data.imread(test_img_path,as_grey=True)

# 画像のサイズの変更の大きさと探索の細かさの指定
img_w = 300
img_h = 150
img_size = (img_h,img_w)

step_w = 5
step_h = 5

# 画像のサイズ変更
test_img = resize(test_img, img_size)

# 画像をスキャンして,各領域の歩行者らしさを計算
likelihood_list = []
for i in range(5):
	for x in range(0,img_w-step_w-PERSON_WIDTH,step_w):
	    for y in range(0,img_h-step_h-PERSON_HEIGHT,step_h):
	        window = test_img[y:y+PERSON_HEIGHT,x:x+PERSON_WIDTH]
	        fd = hog(window, orientations=9, pixels_per_cell=(6,6),cells_per_block=(3,3), visualise=False) ## 領域内のHOG特徴量を取り出し、SVMに入力して学習データと比較
	        estimated_class = 1/(1+(math.exp(-1*detector.decision_function(fd)))) ##SVMの出力値をシグモイド関数で0~1に正規化
	        if estimated_class >= 0.7: ## 領域内の自動車らしさが7割を超えた場合のみ座標を保持
	            likelihood_list.append([x,y,x+PERSON_WIDTH,y+PERSON_HEIGHT])

if len(likelihood_list) > 0:
	for rect in likelihood_list:
		cv2.rectangle(test_img, tuple(rect[0:2]), tuple(rect[2:4]), (0,0,0), 2)

# 歩行者らしさの表示
plt.subplot(111).set_axis_off()
plt.imshow(test_img, cmap=plt.cm.gray)
plt.title('Result')
plt.show()

今回は検出の対象としてCarData/TestImagesにあるtest-3.pgmを用いました。
上のコードを実行すると次のような画像を得られるかと思います。

f:id:cool_on:20160123011415p:plain

右側の自動車は検出できていますが、左側の自動車は検出に失敗していることがわかるかと思います。
上のコードでは一回しか探索を行っていませんが、実際には検出したい画像のサイズを小さくしながら何度もスライディングウィンドウを行う必要があります。
ともあれ(精度の話は抜きにして)、これで自力で特徴量を取り出し、学習させ、検出まで行うことが出来るようになりましたね。

まとめ

駆け足でしたが画像認識の端っこを紹介させていただきました。正直駆け足すぎてまだまだ紹介できてない部分が多いですが今回はここまでとします。
このエントリを最後まで読んでくれた初心者の方がどのくらいいるのかはわかりませんが、本格的に画像処理等を始める際には、そういえばこういうことも出来るんだったくらいの感覚で思い出してもらえると幸いです。

*1:結果的に境界線が抽出出来るという話ですがイメージは大事なのでこう書いています。

*2:調査不足で既に研究されてるかどうかは確認していません。