にせねこメモ

はてなダイアリーがUTF-8じゃないので移ってきました。

Python+OpenCVでアニメのカット検出

はじめに

編集された映像のまとまりで一番細かい単位をカット(英語ではshot)という。カットがつなぎ合わされて一つの映像作品が作られている。
一般的にカットの切り替え時にはカメラの位置や撮影対象の位置関係が不連続になるため、画の大きな変化から目で見て判別のつく場合が多い。

なので、このカットの切り替えを、画の変化から(ある程度)自動で検出することができる。

手法

CG-ARTS協会『ディジタル画像処理[改訂新版]』に、カット検出について、次のように書かれている。

カット検出は,隣り合うフレーム間の画像の差分画像を用いて行うことができ,差の絶対値が大きいとき,その場所がカットであると判定すればよい。しかし,画面中に大きく動く物体がある場合も差の絶対値が大きくなり,カットと誤ることが多いため,精度を上げる工夫が必要である。画像をM×Nの格子(長方形領域)に区分し,長方形領域ごとの画素値の平均値を求め,その平均値の差の絶対値を用いてカットの判定を行う。16×16など比較的少ない格子数を用いることにより,物体の動きに影響されにくいカット検出ができる。

CG-ARTS協会『ディジタル画像処理[改訂新版]』(2015年) p.303

これに従って、フレーム間差分を用いてカット検出を行う。
まず、現在のフレームとそのひとつ前のフレームの差分画像を用意し、それに対してMSE (Mean Squared Error, 平均二乗誤差)やMAE (Mean Absolute Error, 平均絶対誤差)など指標となる値を計算する。その値が閾値以上であるならそのフレームでカットの切替が行われたと考える。

また、『ディジタル画像処理[改訂新版]』では精度を上げる工夫として、元画像を縮小したものに対してカット判定するという手法が紹介されているので、これについても検討する。

カット検出コードの例

Python 3.5.3, OpenCV 3.1.0, NumPy 1.13.1 (Anaconda, Windows 10 64-bit)で動作。

評価

評価するのは結構面倒である。false-positiveはカットとして検出した画像がダブってるのをみれば大体わかるが、false-negativeは検出結果からはわからない。なので、映像に対するカット切り替えタイミングの正解データを用意しておいて、それと対照して正解不正解を確認するというのが妥当だと思う。正解データは手で用意する必要がある。

ここで、ワイプやディゾルブといったカット同士の切り替えがなめらかに行われる場合、はっきりどのフレームで切り替わったということが言えないため、うまい評価法が思いつかなかった。なので、それらについては評価から除外し、カットの切替と検出されてもされなくても同じ評価になるようにした。


閾値以上の値をもつフレームをカットだと判定することから、検証にはthreshold(閾値)を変化させて、その閾値に関してprecision (精度, 適合率)とrecall (再現率)を計算した。

precisionは、カットの切れ目だ!と予測した中で実際に正しくカットの切れ目を判定できていた割合で、
 \displaystyle
P = \frac{\mbox{予測のうち正しかった数}}{\mbox{予測した総数}}
で表される。

recallは、実際に動画の中に存在するカットの切れ目をどれだけ網羅して判定できたかという割合で、
 \displaystyle
R = \frac{\mbox{予測のうち正しかった数}}{\mbox{正解の総数}}
で表される。

ここで、例えば動画全体を通してカットの切れ目を1つしか予測せず、それが正解だった場合precisionは1になるが、動画全体のほとんどのカットの切れ目を取りこぼしているためにrecallは0に近い値になる。
また、逆に全てのフレームをカットの切れ目だと判定する場合、precisionは0に近い値をとるが、recallは1になる。
このように、予測の良さを計るにはprecisionもrecallも単体ではあまり適さないと考えられるため、F-measure (F値)という尺度を導入して指標とする。

F-measureは、precisionとrecallの調和平均である。
 \displaystyle
F = \frac{2PR}{P+R}
これはprecisionとrecallのどちらもが高い値を持つ場合に高くなり、いい感じに評価に使えそうである。高いほど(1に近いほど)性能がよい。

さて、評価用に動画を用意した。会話中心で動きの少ない日常系と、激しいアクションがあるものの2種類のつもり。

各動画はCM等が含まれず、本編映像のみのものである。
これらに対し、カットの切り替わりタイミングのリストを作成した。各ファイルへのリンクを次に挙げる。

実際にはフレーム番号で計算したが、動画プレーヤでの確認のしやすさから時間表記(分:秒.ミリ秒)に直している。
カットの切り替わりタイミングを各行記載した。また、行末に"?"がついているものはカット検出の評価から除外する部分であり、ディゾルブなどで切り替わりに幅があるものについては"-"で繋いで範囲を示した。

評価結果1: ごちうさ

ご注文はうさぎですか?』1話について、いくつかの画像サイズで前フレームとのMSEとMAEを計算し、thresholdに対する検出性能の指標を計算し、グラフを描いた。

1280x720 MSE

f:id:nixeneko:20170903033850p:plain

Max F-measure 0.8344370860927152
Threshold 6947.407446108217
1280x720 MAE

f:id:nixeneko:20170903033858p:plain

Max F-measure 0.9038461538461539
Threshold 55.61545464409722
640x360 MSE

f:id:nixeneko:20170904213100p:plain

Max F-measure 0.8344370860927152
Threshold 6904.933479456018
640x360 MAE

f:id:nixeneko:20170904213115p:plain

Max F-measure 0.9020866773675763
Threshold 55.55679398148148
128x72 MSE

f:id:nixeneko:20170903033732p:plain

Max F-measure 0.8344370860927152
Threshold 6904.933479456018
128x72 MAE

f:id:nixeneko:20170903033756p:plain

Max F-measure 0.9020866773675763
Threshold 55.55679398148148
64x36 MSE

f:id:nixeneko:20170904213140p:plain

Max F-measure 0.8344370860927152
Threshold 6904.933479456018
64x36 MAE

f:id:nixeneko:20170904213148p:plain

Max F-measure 0.9020866773675763
Threshold 55.55679398148148
32x18 MSE

f:id:nixeneko:20170903033808p:plain

Max F-measure 0.8308207705192631
Threshold 5792.83275462963
32x18 MAE

f:id:nixeneko:20170903033816p:plain

Max F-measure 0.888888888888889
Threshold 48.49363425925926
16x9 MSE

f:id:nixeneko:20170903033827p:plain

Max F-measure 0.8264462809917356
Threshold 4685.99537037037
16x9 MAE

f:id:nixeneko:20170903033837p:plain

Max F-measure 0.8803827751196172
Threshold 44.44675925925926
F-measure最大値まとめ

まとめると次のようになる(数値の小数点以下桁数は適当)。

尺度 size max F-measure threshold
MSE 1280x720 0.8344 6947.4
MSE 640x360 0.8344 6904.9
MSE 128x72 0.8344 6904.9
MSE 64x36 0.8344 6904.9
MSE 32x18 0.8308 5792.8
MSE 16x9 0.8264 4686.0
MAE 1280x720 0.9039 55.6
MAE 640x360 0.9021 55.6
MAE 128x72 0.9021 55.6
MAE 64x36 0.9021 55.6
MAE 32x18 0.8889 48.5
MAE 16x9 0.8804 44.4

評価結果2: フリップフラッパーズ2話

フリップフラッパーズ』2話について、いくつかの画像サイズで前フレームとのMSEとMAEを計算し、thresholdに対する検出性能の指標を計算し、グラフを描いた。

1280x720 MSE

f:id:nixeneko:20170904213740p:plain

Max F-measure 0.7517401392111369
Threshold 4725.544186559607
1280x720 MAE

f:id:nixeneko:20170904213750p:plain

Max F-measure 0.8178528347406515
Threshold 49.28785517939815
640x360 MSE

f:id:nixeneko:20170904213759p:plain

Max F-measure 0.7522123893805309
Threshold 4287.165564236111
640x360 MAE

f:id:nixeneko:20170904213810p:plain

Max F-measure 0.8178528347406515
Threshold 49.19312789351852
256x144 MSE

f:id:nixeneko:20170904214121p:plain

Max F-measure 0.7552140504939626
Threshold 4102.424325448495
256x144 MAE

f:id:nixeneko:20170904214112p:plain

Max F-measure 0.8183962264150942
Threshold 47.06980613425926
128x72 MSE

f:id:nixeneko:20170904213833p:plain

Max F-measure 0.7603485838779955
Threshold 3877.8117042824074
128x72 MAE

f:id:nixeneko:20170904213842p:plain

Max F-measure 0.820754716981132
Threshold 46.62550636574074
64x36 MSE

f:id:nixeneko:20170904214100p:plain

Max F-measure 0.7656249999999999
Threshold 3832.808449074074
64x36 MAE

f:id:nixeneko:20170904214051p:plain

Max F-measure 0.8232502965599051
Threshold 46.094907407407405
32x18 MSE

f:id:nixeneko:20170904213856p:plain

Max F-measure 0.7734553775743707
Threshold 3645.484953703704
32x18 MAE

f:id:nixeneko:20170904213907p:plain

Max F-measure 0.8177570093457943
Threshold 43.302083333333336
16x9 MSE

f:id:nixeneko:20170904213922p:plain

Max F-measure 0.7842227378190255
Threshold 3205.7291666666665
16x9 MAE

f:id:nixeneko:20170904213931p:plain

Max F-measure 0.8189349112426034
Threshold 41.75231481481482
F-measure最大値まとめ

まとめると次のようになる(数値の小数点以下桁数は適当)。

尺度 size max F-measure threshold
MSE 1280x720 0.7517 4725.5
MSE 640x360 0.7522 4287.2
MSE 256x144 0.7552 4102.4
MSE 128x72 0.7603 3877.8
MSE 64x36 0.7656 3832.8
MSE 32x18 0.7734 3645.5
MSE 16x9 0.7842 3205.7
MAE 1280x720 0.8179 49.3
MAE 640x360 0.8179 49.2
MAE 256x144 0.8184 47.1
MAE 128x72 0.8208 46.6
MAE 64x36 0.8233 46.1
MAE 32x18 0.8178 43.3
MAE 16x9 0.8189 41.8

考察

  • 全体的にごちうさの方がフリップフラッパーズより検出性能がよい。これは、フリップフラッパーズには激しいアクションが多く、それが誤判定されがちだということが考えられる。
  • MSEよりMAEの方が検出性能がよい。
    • 画素値を[0.0, 1.0]の範囲にスケーリングしたらまた変わるのだろうか。
  • 差分画像を計算するサイズは64x36程度が一番良いようだ。
    • 差分画像はごちうさでは1280x720が最高で次に256x144, 128x72, 64x36が同じ性能で続き、解像度が大きいほど(原サイズに近いほど)性能が良いようにも見えるが、フリップフラッパーズでは64x36のパフォーマンスが一番高かった。
    • ある程度縮小することで計算速度を速くすることができる。
  • 64x36でMAEを計算した場合、最大のF-measureをたたき出したthresholdは、ごちうさ55.6、フリップフラッパーズ46.1程度とそれなりの開きがある。このため、一つのthresholdが任意の動画に対して最高の性能を出すということはない。

前のフレーム一つと比較しただけでは繋がった一続きのカットであるかどうかの判定が難しいものが必ず出てくるため、そういうものに対しても正しく判定するためには、過去や未来のフレームも参考にカット判定をしないといけないが、計算コストも高くなるということもあり、実用上そこまで重要でないような気がする。

例えば、動画エンコードで、カットの切り替わりが検出されたところをIフレームとしたい時などの場合、カットという切れ目よりは画面が大きく動いたことの方が重要であると考えられ、今回のような検出手法でも効果を発揮すると思う。

まとめ

ごちうさ1話とフリップフラッパーズ2話について検討した結果、差分画像を計算してカット検出を行うには、

  • 元画像を64x36程度に縮小
  • 尺度にMAEを用いる
  • 閾値は45~55程度(動画によって最適なものは異なる)

とするとよさそうで、0.8~0.9程度の最大F-measureが達成できるようである。
とはいえ、2つの動画作品に対してしか検証していないので、どこまで一般化できるかはわからない。

応用例: 映像のダイジェスト作成

カットの切り替わりが検出できるのであるから、カットごとに一つの画像を取り出すことで、映像のダイジェストを作成することができる。
f:id:nixeneko:20170904225533p:plain
見るとわかるが、画面のなかの大きな部分が動いたり色が変わる場面では誤検出が起きている。

他に試す価値があるかもしれないもの

  • ヒストグラムを利用したカット検出
  • SURF, ORBなどの画像特徴量の利用

*1:ウサ耳の生えたココナちゃんが齧歯類特有の前歯を削るために硬い構造物に齧りつき、唾液の糸が引くシーン、最高です。あと、色彩設計的にもポップで楽しい回。

Pillow, OpenCVなどでの画像の扱いの違い

Pythonには画像処理のために画像を読み書きするライブラリがあり、画像ファイルをnumpy.ndarrayの形で読み込んだりそれを表示・保存したりできるものがある。
一方で、各ライブラリによって画像の形式がまちまちであったりして、同じnumpy.ndarrayでも変換が必要だったりする。
ここでよく引っかかるので調べてまとめておく。

環境

  • Python 3
    • Python 3.5.3 |Anaconda custom (64-bit)| (default, May 15 2017, 10:43:23) [MSC v.1900 64 bit (AMD64)] on win32
  • NumPy: 1.13.1
  • OpenCV Python bindings (cv2): 3.1.0
  • Pillow (PIL): 4.2.1
  • SciPy: 0.19.0
  • matplotlib: 1.5.1

以下、RGBカラー画像(8 bit/channel)を読み込むことについて考える。

略記

  • w: 幅
  • h: 高さ
  • c: チャンネル数(RGB画像の場合は3)

例示するコードではnumpyはnpとしている:

import numpy as np

Pillow (PIL fork)

Imageはnumpy.ndarrayではないが、numpy.asarrayなどを使って相互変換できる。

import numpy as np
from PIL import Image

pil_img = Image.open(INFILE)
ary_img = np.asarray(pil_img)

Image.openで画像ファイルを開いて、numpy.asarrayでnumpy.ndarrayに変換する。
形は(h, w, c)。
numpy.asarrayで特に指定しないとdtype=uint8 [0, 255]で読み込まれる。また、dtype=np.floatなどと指定しても[0.0, 255.0]と値は変わらないので必要に応じて自分でスケールする必要がある。

numpy.ndarrayからPIL Imageへ

Image.fromarrayでできる。与えるのはuint8である必要があるらしい。

from PIL import Image
im = Image.fromarray(np.uint8(myarray*255))

Imageのリサイズ

画像をImageのインスタンスで保持している場合、Image.resizeでリサイズができる。
次は画像のサイズを半分にする例。

pil_img = Image.open(INFILE)
print(pil_img.size) # (w, h)
w_orig, h_orig =pil_img.size
resized_pil_img = pil_img.resize((w_orig//2, h_orig//2))

ここで、pil_img.resizeに指定するのは(w, h)の形である。


他、<Imageインスタンス>.show()で画像を表示できるっぽい。

OpenCV 3 (cv2)

OpenCVで扱うカラー画像は基本BGRである。これは歴史的な経緯があるらしい。

cv2.imread

import cv2
img_cv2 = cv2.imread(INFILE)

cv2.imreadで画像ファイルを開くとnumpy.ndarrayが得られる。
これは(h, w, c)の形ではあるが、チャンネルの並び順が他のライブラリと異なりBGRになっている。
また、何もしないとdtype=uint8 [0, 255]で読み込まれる。

同様に、cv2.imshow, cv2.imsave, cv2.VideoCapture, cv2.VideoWriterなどもBGRの順であることを前提としているため、cv2以外と組み合わせて利用する場合には、BGR-RGB間の変換をする必要がある。

cv2.imshowなどに渡す画素値の範囲

画像表示・出力用のcv2.imshow, cv2.imwriteなどの関数に与える画像の画素値の範囲は、uint8であれば[0, 255]、uint16は[0, 65535]、int16は[-32768, 32767]、floatであれば[0.0, 1.0]であるらしい(値の小さい方が黒、大きい方が白となる)。floatで[0.0, 1.0]の範囲からはみ出た値は、黒(0.0)または白(1.0)になる。

BGR画像→RGB画像への変換

img_bgrが(h, w, c=3)のBGRなnumpy.ndarrayとする。

img_rgb = img_bgr[:,:,::-1]

Pythonのスライスを使う。「第1, 2次元目はそのままで、第3次元目は逆方向に並べたもの」という風に指定している。

RGB画像→BGR画像への変換

img_rgbが(h, w, c=3)のRGBなnumpy.ndarrayのとき、

img_bgr = img_rgb[:,:,::-1]

項目を分けたが、BGR→RGBと全く同じ。

cv2.resize

一方で、画像のリサイズを行うcv2.resize関数に引数として渡すリサイズ後のサイズは(w, h)である。

resized_img = cv2.resize(img, (w, h))

これは、画像のnumpy.ndarrayの形が(h, w, c)であることを考えると、ちゃんと確認しないと引っかかりそう。
次は画像のサイズを半分にする例。

h,w,c = img.shape
resized_img = cv2.resize(img, (w//2, h//2))

cv2には空の画像を作成する関数はないっぽいので、numpy.zeros, numpy.onesなどを利用する。

matplotlib

matplotlib.image.imread

matplotlib.image.imreadが返す配列は(h, w, c)でRGB、dtype=float32、[0.0, 1.0]。

Return value is a numpy.array. For grayscale images, the return array is MxN. For RGB images, the return value is MxNx3. For RGBA images the return value is MxNx4.

https://matplotlib.org/api/image_api.html

matplotlib.pyplot.imshow

matplotlib.pyplot.imshowは、3チャンネルカラー画像であればRGB (float or uint8)を要求する。floatの場合は[0.0, 1.0]。

import matplotlib.pyplot as plt
import matplotlib.image as mpimg

img_plt = mpimg.imread(INFILE)

plt.figure()
plt.imshow(ary_img) 
plt.show()

SciPy

scipy.misc.imread

import scipy.misc
img_scp = scipy.misc.imread(INFILE)

PILを使って画像を読み込んでるっぽいのでPIL互換か。
dtype=uint8, (h, w, c), [0, 255].

scipy.misc.imsave

dtype=float32なnumpy.ndarray与えたら、[0.0, 1.0]でも[0.0, 255.0]でもどちらでもまともに出力された。
これは、uint8でないと、最小・最大の画素値を用いて正規化されるかららしい。そのため、画像の中身をちょっと見てみたい時にはいいと思うが、値が変わらないでほしい時には使えなさそう。

ChainerなどのCNNレイヤの入出力

CNN系のchainer.links.Convolution2Dは入力の形として(c, h, w)を前提とする。hとwは入れ替わっても一貫していれば問題ないが、チャンネルは最初に来る必要がある。
また、チャンネルの順番はRGBでもBGRでもなんでもいいが、一貫している必要がある。

次にあげる類のものは設計次第で何でもいいが、与えるデータを一貫して揃えておかないといけない。

  • 高さと幅の順番
  • チャンネルの並び順 (RGBかBGRか? など)
    • cv2とそれ以外を交ぜて使うと引っかかりそう
  • 画素値の範囲(とfloatにするかintにするかなど)
    • [0, 255], [-1.0, 1.0], [0.0, 1.0]、など。
(h, w, c)から(c, h, w)への変換

numpy.transposeを使う。img_hwcが(h, w, c)の形のnumpy.ndarrayだとすると、

img_chw = np.transpose(img_hwc, (2, 0, 1))
(c, h, w)から(h, w, c)への変換
img_hwc = np.transpose(img_chw, (1, 2, 0))

ミニバッチ対応

CNN系のchainer.links.Convolution2Dは入出力がミニバッチであるので、一つの画像を入力として与える場合にはnumpy.newaxisなどを使って先頭に次元を増やしてミニバッチサイズ1のミニバッチにしないといけない: (1, c=3, h, w)とか。

minibatch = img_chw[np.newaxis]

ミニバッチから取り出すのは

img_chw = minibatch[0]

とか。

値の範囲を変換

単にnumpyのブロードキャストの機能使って加減算や乗除算を行うだけ。一緒にnumpy.ndarray.astypeを使って形式の変換を行うことも多いと思う。

例: [0, 255] -> [0.0, 1.0]
a = a.astype(np.float)/255.0
例: [0, 255] -> [-1.0, 1.0)
a = a.astype(np.float)/128.0 - 1.0
例: [-1.0, 1.0] -> [0, 255]
a = ( (a + 1.0)*127.5 ).astype(np.uint8)

とか。

長方形で厚くて硬い万引き防止タグを分解してみた

よく電気屋などで小型商品にくっつけてある万引き防止タグ、検知方式によっていろいろな形があるのだが、長方形で立体的に厚みがある硬いタグを分解してみた。
f:id:nixeneko:20170809134741p:plain

このタグは使い捨てで、粘着テープが裏についていて、商品に貼りつけられる。このタグがゲートを通ると検知され、警報が鳴る。会計時に無効化処理を行うことで、会計を済ませた商品がゲートで反応することを防いでいるらしい。

分解

f:id:nixeneko:20170809134948p:plain
f:id:nixeneko:20170809135112p:plain
f:id:nixeneko:20170809135422p:plain
さて、分解してみると、底面に固定された金属板1枚と、金属の薄いフィルム2枚が入っている。フィルムの方は固定されていなくて、プラスチックで囲われたタグの内側の空間の中で動くことができるようになっている。タグの厚みはこのフィルムが動く空間を作るためのもので、タグが固いのも同じ理由だろう。

さて、随分シンプルな構造であるが、いったいどういう原理で動作するのだろうか。

原理

調べてみると、このタグはAcousto-magnetic systems (音響磁気方式)という方式のものらしい。Wikipediaに解説がある。

それによると、防犯ゲートの送信アンテナから送信されるパルスによって中に入っている金属フィルムが共振し、その共振によって発生する電波をゲートが受信するとアラームを起動するということらしい。

また、一番下の金属板が磁化されていると有効になるが、磁化が解除されると無効化され、ゲートに反応しなくなるとのことである。
つまり、解除機の無効化処理とは、実際には磁化の解除を行っているのだろう。

もしかして、解除されたタグについても、磁石を隣に置いておけばゲートに反応するようになるのかもしれない。


しかし、こんなに簡単な仕組みで防犯タグが実装されてるのを見て感心してしまった。解除もできる様になってるし。

イオニア数字変換TeXマクロ

イオニア数字ネイティヴではないので、イオニア数字を書く場合にはいちいち調べて書かないといけないのだけれど、実際面倒なので、(La)TeXマクロにしてLaTeXなどで書く際に簡単に変換できるようにしようというのが今回の目的。

イオニア数字というのは、古代ギリシャで使われた数値の書き方であって、ギリシャ文字を使って数値を表現するものである。一般にギリシャ数字とも呼ばれるとのこと。
詳しくはWikipediaなどを参照: ギリシアの数字 - Wikipedia

サンプル

f:id:nixeneko:20170809031334p:plain

コード

Githubに上げた。XeLaTeXでコンパイルできる。マクロの出力結果について網羅的に確認したわけではないので不適切な部分があるかもしれない。
GitHub - nixeneko/ionicnumber: A TeX macro that does a conversion from a number into Ionic numeral system.

使用法

ionicnum.texの中で\ionicnumマクロが定義されている。これを使うには、数字を与えて

\ionicnum{1234}

のようにする。1234の部分は任意の数字が入る。

メモ

イオニア式の記数法には年代や学者等によりいくつかの変種があり、特に10000以上の書き方はさまざまであるらしい。
この実装で用いたものは次のようなものである:

  • 数字にはギリシャ文字の小文字を用いた。
  • 数字はギリシャ文字列の右肩にʹを付けることによって表現した。
  • 6にはスティグマϛを、90のコッパはϟを、900のサンピはϡを用いている。
  • 10000以上はmyriad Μにより、Μの上に一万~一千万の位の数字を書くことによって表現した。
  • 最大で対応している数は一億(100,000,000)であり、ΜΜʹで表す。

The largest number named in Ancient Greek was the myriad myriad (written MM) or hundred million.

https://en.wikipedia.org/wiki/Myriad

実装について

まず、一~千の位の数字を変換することを考える。
これは、元の数をnとすると、各位の数字は

  • 一の位:  \displaystyle n \mod 10
  • 十の位:  \displaystyle \left\lfloor \frac{n}{10} \right\rfloor \mod 10
  • 百の位:  \displaystyle \left\lfloor \frac{n}{100} \right\rfloor \mod 10
  • 千の位:  \displaystyle \left\lfloor \frac{n}{1000} \right\rfloor \mod 10

となる。次にこれを\ifcaseを使ってギリシャ数字の各文字に変換する。


次に一万以上の数値について。
元の数値を10000で割ったものについて、その値をΜの上に積み重ねて表示する。数値の表示は一~千の位の数字に変換するマクロを共用している。
また、このとき、10000がΜとなるように条件分岐を行っている。


一億以上の数に対応するために再帰っぽい感じで書いてしまったのだが、どうせ処理系が 2^{31}-1 = 2147483647までしか対応してないので、ごちゃごちゃするだけであまりメリットなかったように思う。

20180328追記

気が向いたら修正する……。

Chainerでアニメキャラの目からハイライトを消す

pix2pix

pix2pixというモデルがある。入力画像と、それと一対一対応する変換ターゲットの画像を用意すると、その間の変換を自動で学習してくれるというものである。
元論文はこれ:

この論文内では、モノクロ写真に着色するだとか、線画に色を付ける、航空写真から地図を生成する、ラベリング画像から写真を復元する、などが行われている。要するに、何らかの画像のペアを用意すれば、ペア間の画像変換が学習できるっぽい。


それで、入力画像としてアニメキャラの顔の画像を用意し、出力画像として入力画像から目のハイライトを消したものを用意すれば、アニメキャラの目からハイライトを消すモデルが学習できないだろうか。

幸い、pix2pixのChainer実装が公開されている:

ので、これを改変することによって作成していく。

環境

  • Windows 10 64-bit
  • Python 3.5.3 (Anaconda 64-bit)
  • Chainer 2.0.1
  • CuPy 1.0.1
  • OpenCV 3 (demo.pyの結果表示で使っている)

データセットの用意

まず、データセットがなければ話にならないので用意する。

2017年1~3月に放送されていた*1アニメのキャプチャから、OpenCVのカスケード分類器とlbpcascade_animeface.xmlを利用してアニメ顔を抜き出した。元動画が1280×720 pxとした時に256×256 px以上の解像度をもっているものだけ抽出した。

(20210223追記: データセットを公開しました。次のブログ記事をご参照ください: アニメキャラの目のハイライト消しデータセット - にせねこメモ)

それから、切り出された画像を目で見て、誤検出やブラーがひどいものなどを取り除いた*2

さらに、そこからランダムに500枚抜き出し、それらのコピーに対してSAIを使って手でハイライトを塗りつぶした画像を用意した*3。中には塗りつぶす必要のない画像もあったが、だいたい一枚あたり1~10分程度かかった。特に、目にグラデーションがかかっていたり、タッチ線が入っていたり、ハイライトに大きなグローがかけられていたりするなどの複雑な目の処理をしている場合は難しく、時間がかかった。
用意したデータセットに含まれる画像の例は、下にある結果セクションの入力とground truth(正解画像)を参照。

コードの変更

先ほど挙げたpix2pixの実装においては、Facade datasetから、入力用のラベリング画像を12 chとして、変換ターゲットの画像を3 chのnumpy.arrayとして読み込み、これらによってトレーニングを行っている。これを変更し、入力・出力ともに3chのカラー画像を扱えるものにする。

画像をデータセットとして読み込むプログラムをimg_dataset.py、このデータセットの上で訓練するコードをtrain_dehighlight.pyとして用意した。

ここで、先ほどデータセットとして用意した画像500組のうち、9割(450枚)を訓練用として、残り1割(50枚)を評価用として分割している。

コード

GitHubに上げた。
github.com

デモ

python demo.py -g <GPU番号> -i <画像ファイルのパス>

レーニン

python train_dehighlight.py --epoch 400 --gpu <GPU番号> 

などとして実行する。GTX 1060使って18時間とかだった気がする。GPU使用しないと凄く時間がかかる。実際トレーニングをしてみて感じたことであるが、50,000 iter以上繰り返してもほとんど結果が改善しているようには見えなかった。

結果

176,000 iter目のトレーニング結果を次に挙げる。

入力

f:id:nixeneko:20170801195435p:plain

出力 (変換結果)

f:id:nixeneko:20170801195606p:plain

Ground truth (正解画像)

f:id:nixeneko:20170801195542p:plain
上手くいっているところを選んだとはいえ、割といい感じにハイライトが消えてる気がする(たのしい)。

その他変換結果

訓練セットにも評価セットにも含まれない画像についての変換結果を載せる。トレーニング176,000 iter目のモデルを用いた。左が入力、右が出力である。絵は自作なので絵柄にバリエーションがないのはご愛敬。
f:id:nixeneko:20170801205608p:plain
f:id:nixeneko:20170801210236p:plain
f:id:nixeneko:20170801205943p:plain
何となく塗りつぶした跡が見えるが、そこそこうまく動いてる様に思う。

改善点など

現状、入力画像のうち顔の領域の大きさが256×256 px程度である場合にはうまくいくが、大きさが大きかったり小さかったりするとあまりうまくいかない。つまり、スケール不変性がない。
これは、カスケード分類器で切り出した顔画像を256×256に変形してそれを用いてトレーニングしているためであると考えられ、data augmentationなどで訓練画像として様々なスケールの入力が与えられるようにすることである程度は対応できるのではないかと思う。

また、元画像で目のハイライトが小さい場合にはあまり効果が感じられない。これについてもdata augmentationで様々にスケールを変更したものを入力とすることで多少改善するとは思うが、どうなんだろう。

更に、訓練画像を抽出したカスケード分類器の特徴からか斜めや横向きの顔の画像が少なく、正面向きではうまく働くが、横向くとハイライトが消えなかったりする様である。

後、訓練されたモデルはほとんど顔しか知らないので、画面に文字などが被さってる場合も文字のあたりを目のハイライトとして勘違いするらしく、暗くなったりした。

他にも、訓練に使った画像の作品以外の画像だとハイライトが消えづらいということがあった。目の描き方は作品によって千差万別で、様々な絵柄に対応するためには訓練画像のバリエーションを増やすしかないように思う。何にせよ訓練データセットの作り方次第で改善の余地は大きそうな気がする。

*1:一部再放送を含む。

*2:つらい

*3:とてもつらい

ChainerCVでFaster R-CNNを使って顔検出してみる

(この記事は手探りで書いてるので大いに勘違いを含んでいる可能性があります。ご了承ください。)

VGG16をベースとしたFaster R-CNNを使って顔検出を行うというテクニカルリポートがあった。

ここでは、WIDER FACEという顔検出用のデータセットの上でトレーニングを行っている。

これを再現してみたい*1

手法

ChainerCVにFasterRCNNVGG16というそのものズバリのものがあるので、これを使ってトレーニングをしてみて、顔検出をやってみる。
今回はChainerCVの、Faster RCNNのサンプルコードchainercv/examples/faster_rcnn/を改変することによって行った。サンプルコードでは、PASCAL VOCデータセットに従ってトレーニングをし、20種類の物体が認識できる様だった。

ここでは、要するに、顔だけを検出するので、物体認識において検出できるクラスの数を1とすれば、とりあえずは動くはずである。
あんまりよくわかってないので、ハイパーパラメータはサンプルコードのものをほぼそのまま利用している。

環境

依存ライブラリ

  • Chainer 2.0.0
  • CuPy 1.0.0.1
  • ChainerCV 0.5.1
  • SciPy

ソース

github.com
なお、トレーニング済みのモデルはデータ容量が大きいのでリポジトリに含んでいないので、以下のリンクからダウンロードして trained_model/snapshot_model.npz に置くか、あるいは demo.py などを実行時に自動でダウンロードされるようになっている。


(2018-04-04追記)
chainercv v0.8.0ではトレーニング済みのモデルデータとして上の代わりに次のリンクのものを使う必要がある。

これは、chainercvがv0.6.0より後、v0.8.0までの間に、FasterRCNNVGG16の構造が変化してしまったからのようである。ついでにdemo.pyとtrain.pyをv0.8.0で動く様に修正した。
(追記終)

デモ

python demo.py [--gpu <gpu>] [--pretrained_model <model_path>] <image>.jpg

などとすれば、次のように顔と予測された矩形領域とスコアが表示される。
f:id:nixeneko:20170724160758p:plain
なお、faceと表示されてるのはPASCAL VOCの多クラス検出のコードを流用したためで、冗長になっている。

トレーニング手順

まず、顔検出用のWIDER FACEデータセットをダウンロードする。

ここから

  • Wider Face Training Images
  • Wider Face Validation Images
  • Face annotations

をダウンロードしてきて展開する。

そして、次のようにトレーニング向け/評価向けデータセットの、画像ファイルの含まれるフォルダとアノテーションデータのパスを指定して train.py を実行する。

python train.py --gpu=0 --train_data_dir="WIDER_train" --train_annotation="wider_face_split/wider_face_train.mat" --val_data_dir="WIDER_val" --val_annotation="wider_face_split/wider_face_val.mat"

やや細かい説明

chainercv/examples/faster_rcnn/のサンプルでは、Pascal VOCデータセットで物体認識をトレーニングしているので、ここで使われいてるVOCDetectionDatasetと互換性のある出力をするデータセットを、WIDER FACEデータセットを読み込ませるに際して用意している。
これが wider_face_dataset.py である。chainercv/chainercv/datasets/voc/voc_detection_dataset.pyを改変して作成した。


さて、このデータセットの上でトレーニングしたところ、RuntimeErrorが出たので、エラーが出た画像ファイルをトレーニング時に使用しない様に抜くようにしている。エラーがでたファイルは blacklist.txt にリストアップしてある。抜かない場合、トレーニング途中でlossがnanになってしまい失敗した。

なお、ライブラリのchainercv/links/model/faster_rcnn/utils/bbox2loc.pyの

    dh = xp.log(base_height / height)
    dw = xp.log(base_width / width)

を、

    _dh = xp.fmax(base_height / height, xp.finfo(xp.float32).eps)
    _dw = xp.fmax(base_width / width,  xp.finfo(xp.float32).eps)
    dh = xp.log(_dh)
    dw = xp.log(_dw)

などと変更して、logに与える値が0以下にならないようにしたらnanにならなくなった。

検出結果

緑がground truth、青が検出結果である。スコアが低いほど色が薄くなっている。

割とうまく行った例

f:id:nixeneko:20170724162340p:plain

うまく行かない例

f:id:nixeneko:20170724162432p:plain
ブラーのかかった顔、小さい顔などは2重に検出されていたり、検出されなかったりする。また、検出されてもずれがおおきかったりしているようだ。
見たところ、検出できる領域の最小があまり小さくなく(あまり小さい領域は検出できない)、動かす幅にも制限がある気がする。パラメータの設定次第では改善するのかもしれない? 割と大きなサイズをもった顔はちゃんと検出されているようだ。

*1:再現できているとは言っていない。