画像の見た目の変化を抑えつつ任意の二値画像データを埋め込む

この記事はぱくとま Advent Calendar 2016の10日目の記事です。

はじめに

まずはじめに断っておくが、私はぱくとま氏について何も知らないしツイッターのアカウントもフォローしていないという状態である。そんな私が何故このアドベントカレンダーに寄稿するに至ったかというと、実は数日前にこんなやりとりがあった。

寄稿に至るツイッターバトル

遡ること4日、私(_96N_)に対してpotato4d氏によりこのアドベントカレンダーに寄稿するよう持ちかけられた。

唐突である。
本人について何も知らない人間にいきなりアドベントカレンダーを書くよう要求する行為は違法だと思う。

人間性を盾に反撃する私。

何でも貫く矛で突き刺してくるpotato4d氏。

破壊された人間性。

なんて話しているうちに本アドベントカレンダーに寄稿することになったわけだが、生憎私はぱくとま氏について何も知らないので書くことが無い。参考にしようと思ってアドベントカレンダーを遡り記事を読んでみたが、九割九分九厘ぱくとま氏をdisるだけの記事で参考にならないし涙を禁じ得ない。リアルで知り合いのowl_8は「記事のどこかに'ぱくとま'って入ってたらどんなこと書いてもいいよ」なんて言っていたのできっと適当なのだろう。大体potato4d氏が全部悪い。なので今回はぱくとま氏とはほぼ無関係の記事になっている。許して欲しい。

画像にデータを埋め込むということだが難しいことは特にないので気楽に読んでいただけると幸甚である。


画像の見た目を変えずにデータを埋め込むよ

今回の方法では濃淡画像の8bitのLSB(Least Significant Bit)、つまり最下位ビットに二値画像を埋め込む。具体的に言うと、濃淡画像の最下位ビットの画素値を二値画像の画素値で置き換える。話を簡単にするために今回は濃淡画像を用いるが、考え方はフルカラー画像でも同じである。まずは以下の2枚の画像を見て欲しい。


f:id:cool_on:20161210005347p:plainf:id:cool_on:20161210005350p:plain


一見何ら違いは無い絵である。
ところが、実は右側の画像には以下のような二値画像のデータが埋め込まれている。
f:id:cool_on:20161210005658p:plain
画像見比べてもわからんぞ!と思われるかもしれないが、次に述べる簡単なトリックがある。

濃淡画像に二値画像を埋め込む

まず濃淡画像と二値画像について軽く触れておく。濃淡画像とは1画素あたり256階調(0〜255)で表現されるグレースケールの画像であり、1画素で8bitを要する。二値画像とは1画素あたり2階調(任意の二色を0,1に割り当てる、一般的に白と黒)で表現される画像であり、1画素で1bitを要する。二値画像は任意の二色から表現されるが本質的には0と1のみのデータで、出力時に0と1に対する色の割当を行っているだけである。

次の二枚は私の大好きな日野茜ちゃんと、そこから取り出した最下位ビットの二値を白と黒に割り当てた二値画像である。

f:id:cool_on:20161210014130p:plain f:id:cool_on:20161210014133p:plain

見ていただくと分かると思うが、最下位ビットはほぼノイズでしか無い。つまり最下位ビットは元画像に対してほとんど影響を与えないということである。実際、最下位ビットは画素値に対して±1程度の影響しか与えないので変化させても肉眼では捉えられない。また、最下位ビットが元画像にほとんど影響を与えないということは、最下位ビットに任意のデータを与えても元画像はほとんど変化しないということである。これが最下位ビットにデータを埋め込む理由である。

それでは種明かしも終えたので実際に埋め込んでいこう。前提として濃淡画像と二値画像の画素数は同じであるとする。手順的には

  • 埋め込み先の濃淡画像の最下位ビット成分を除去する(各画素の最下位ビットの値を0にする)
  • 埋め込みたい二値画像の各画素の値をハイディング先の濃淡画像の対応する画素に加算する。

たったこれだけである。最下位ビットを書き換えてしまうのでこの方法は非可逆である点だけ注意したい。
以下はPython2.7とOpenCV2.4.9を用いて実際に埋め込むコードである。

#!/usr/bin/env
# coding:utf-8

import cv2
import numpy as np

#濃淡画像、二値画像の読み込み
img = cv2.imread('pakutoma.png', 0)  #グレースケールで読み込む
img1 = cv2.imread('kuso.png')  #0を0に、1を255に割り当てて表現することで擬似的に二値画像として扱っている。

#まずは濃淡画像から最下位ビットの成分を除去
for col in img:
  for comp in col:
    if comp%2:
      comp -= 1

#次に濃淡画像の最下位ビットに二値画像の画素値を加算
for col, col1 in zip(img, img1):
  for  comp, comp1 in zip(col, col1):
    if comp1!=0:
      comp += 1

cv2.imwrite('result.png', img)


濃淡画像pakutoma.pngに二値画像kuso.pngを埋め込んでいる。
下の左の濃淡画像に右の二値画像を埋め込んだ形となる。


f:id:cool_on:20161210024826p:plain f:id:cool_on:20161210024842p:plain


このようにしてデータを埋めこんだ画像データがたまにCTFの問題として与えられるので頭の隅に入れておくとよい。仕組み上画像ではなく任意のデータを二進数に変換し行列に並べたものを埋め込むこともできる。データの取り出しは最下位ビットから二値画像を作るだけで良い。


おわりに

全く知らない人に挟まれて記事を書くという行為には厳しいものがある。先人達がひたすら「ぱくとまはクソ」と仰ってたのでそこだけ使わせて貰ったが、どうか許して欲しい。この記事を書いてる途中、3秒に一回くらいのペースで日本語の勉強をやり直したい衝動に駆られた。

次回はcueikusuta氏のようです。是非とも濃厚なぱくとま氏の記事にしてあげて欲しい。

ぱくとまはクソ。

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:調査不足で既に研究されてるかどうかは確認していません。

始業

無事進級しました
出欠確認の名簿見てたらなかなか名前が見当たらなくて一瞬留年したのかと思った

2日ほどネットのない環境で暮らしていた

PythonでWAVE

日記に使うつもりでブログを再開したわけではないのでそろそろコードを残しそうと思う。

Pythonでwavファイルを作成しようと思い、WAVEフォーマットの構造を見ることにした。

http://www.kk.iij4u.or.jp/~kondo/wave/

WAVEフォーマットの構造はここでだいたい理解。

んでもって書き込みは

komaken.me

ここを参考にstructモジュールを使って書き込んだ。

書き込むデータは440Hzの正弦波にした。

# -*- coding: utf-8 -*-

import math
import struct

SAMPLING = 44100   # サンプリング周波数
CHANNEL = 2   # 1はモノラル , 2はステレオ
GAIN = 440   # ラの音の周波数
SEC = 3

wf = open("sin_wave.wav","wb")

for i in xrange(SEC):     
    for j in xrange(SAMPLING):
        plot = 30000.0*math.sin(2*math.pi*GAIN/SAMPLING*j)
        a = math.floor(plot)   # plot以下かつ最大の整数(要は床)
        # 8bitで書き込む
        if CHANNEL == 2:
            wf.write(struct.pack('h',a))   # 2チャンネルの場合左
        wf.write(struct.pack('h',a))   # 2チャンネルの場合右

size=wf.tell()   # 波形データのサイズを保存
wf.seek(0,0)   # 書き込み場所をファイルの先頭に

# ヘッダを書き込む
wf.write("RIFF")
wf.write(struct.pack('I',size+44))   # ヘッダのサイズは44バイト
wf.write("WAVE")
wf.write("fmt ") 
wf.write(struct.pack('I',16))
wf.write(struct.pack('H',1))
wf.write(struct.pack('H',CHANNEL))
wf.write(struct.pack('I',SAMPLING))
wf.write(struct.pack('I',SAMPLING*CHANNEL*2))
wf.write(struct.pack('H',2*CHANNEL))
wf.write(struct.pack('H',16))
wf.write("data")
wf.write(struct.pack('I',size))

wf.close()

って感じに書くとポンとファイルが出来てめでたしめでたし

店長

バイト先の店長めっちゃ将棋強い