ここから本文です

書庫全体表示

 本の制作で地味に大変なの作業のひとつが「写真の選定」である、という話は最近いろいろなところでしています。最新作の『失われた205を求めてⅡ』ではそれについてサークル京都支部長である"お茶"と語っていますし、私自身事あるごとに愚痴を漏らしています。

 いったいなぜそれほどまでに大変かと言うと、これまでに撮影してきた横浜線の写真があまりにも多すぎるので、「昔撮った臨時回送列車の写真をここに使いたいな〜」などと思い始めようものなら、数十分は簡単に無駄にしてしまうのです。さらに、編集中は「次のH014編成の写真だけど、これまでに載せた写真と撮影地は被らない方がいいし、配置からして右向きでカーブの構図がいいな……」といった感じで、膨大な写真から細かい条件に見合うものを探し出すという途方もない作業が"頻繁に"発生します。
 そんな無駄の多い作業も流石に凝りてきたので、最近はファイル名に「撮影日付」と「編成番号」を入れることにしました。検索時に編成番号を入れれば、その編成の写真だけピックアップされるという当たり前のことにひどく感動。しかし、その感動も束の間……問題に直面しました。もうお分かりでしょう。

正直、面倒くさい。

 写真を開き、日付と編成番号を確認して手入力。あほらしい。しかもハードディスクにはリネームしていない過去3年分のデータが眠っています。これは無理だ。

 ある日、Twitterで「入力された二次元画像から絵師の性別を当てる」という面白いアカウントがあることを知った私は、ふと大学でOpenCVを触ったのを思い出します。
 OpenCVとは、画像や動画の処理・解析を手助けしてくれるオープンソースの巨大なライブラリで、各種OSに対応しており幅広い分野で使われているものです。大学時代は、OpenCVを顔認識や画像変換程度にしか使っていませんでしたが、折角の機会なのでもう少し遊んでみることにしました。

 そんなわけで、
「横浜線E233系の写真のファイル名を、ボタンひとつで撮影日+編成番号に変える」
ソフトウェアの開発を目指します。


 ファイルの読み込みなど基本的なところはさておき、最大の問題は「編成番号をどのように推定するか」ということです。今回は編成写真を対象に考えているので、編成写真の車両先頭部分から編成番号部分のみを抜きだし、文字化するという手順になります。
 OpenCVには標準で顔認識用の分類器が付属しています。分類器は大量のサンプル画像を集めれば自作することもでき、「猫の顔検出器」や「アニメ顔検出器」など偉大な先人による様々な分類器が公開されています。しかし、今回は「横浜線の編成番号」を検出しなくてはなりません。当然そんなものはあるわけがないので、作ります。
 編成番号の大きさは写真全体のサイズに比べて小さいので、最初から編成番号を当てにいこうというのは無謀です。そこで、まずは車両の先頭部を検出することを考えます。

 コンピューターに「どんな画像が正しく」「どんな画像が違うのか」を学習させるため、まずはポジティブ画像(=正解とされる画像)を集めます。およそ7000枚必要と言われていますが、流石にE233系の写真はまだ7000枚もないので、手元にあった600枚を使います。
イメージ 1

 元写真をひたすら切り抜くこと3時間、傾きや光線、構図がばらばらの正面画像データが600個できました。完成したファイルはサイズがバラバラなので、全て同じサイズになるようにフリーソフトで400px四方に一括変換。ファイル名はスペースが混ざると機械学習時にエラーとなるため、一括リネームして000〜599.jpgとしました。
 さて、やっとの思いで作り上げた正解画像一覧ですが、学習データには「不正解画像」も必要です。不正解画像はネガティブ画像とも呼ばれますが、これが少なすぎると学習が十分に行えません。しかし、具体的な数の基準はなく、その中身も「正解でなければ何でもいい」という具合。先人の知恵を借りようにも、ポジティブとネガティブの比は2:1が望ましいという意見もあれば、1:2のほうがよいという意見もあり、全く役に立ちません。今回はとりあえず1000枚を用意することにし、ほかの電車や風景、住宅、人の写真などありとあらゆる画像を放り込みました。画像サイズはポジティブ画像に合わせ、こちらも400px四方に一括変換し、ファイル名も000〜999.jpgに。
イメージ 2

 OpenCvのbinフォルダに、「pos」「neg」「cascade」フォルダを作成し、ポジティブ画像はposフォルダに、ネガティブ画像はnegフォルダにそれぞれ保存して下準備は完了。
 続いて、学習データをコマンドプロンプトを使って作成します。[参考1][参考2]
 まず、正解画像は1つのベクトルファイルにまとめなくてはならないため、早速OpenCVに付属しているツールを使います。OpenCVは最新バージョンが3ですが、2.4と3.0では色々と変わりすぎていて大学時代苦労した思い出があるため、馴染みの深い2.4を使いますw

 1.コマンドプロンプトを管理者権限で起動

 2.初期階層を移動
  OpenCVがC:\直下にある前提での記述です。保存先がC:\直下でないと、作業途中で
  エラーを吐くことがあるようです。x64,vc12は作業環境に合わせて。 
  cd C:\opencv\build\x64\vc12\bin

 3.ポジティブ画像の一覧を作成
  コマンドプロンプトを使えば、指定したディレクトリに含まれるファイルの一覧を一括
  で出力できます。ただし、ポジティブ画像は座標情報が必要なため、出力されたファイ
  ルの末尾を補います。

  (置換前) poslist.txt
  C:\opencv\build\x64\vc12\bin\pos\000.jpg
  C:\opencv\build\x64\vc12\bin\pos\001.jpg
  C:\opencv\build\x64\vc12\bin\pos\002.jpg
  C:\opencv\build\x64\vc12\bin\pos\003.jpg
  C:\opencv\build\x64\vc12\bin\pos\004.jpg
  ……
  (置換後) [jpg]→[jpg 1 0 0 400 400]
  C:\opencv\build\x64\vc12\bin\pos\000.jpg 1 0 0 400 400
  C:\opencv\build\x64\vc12\bin\pos\001.jpg 1 0 0 400 400
  C:\opencv\build\x64\vc12\bin\pos\002.jpg 1 0 0 400 400
  C:\opencv\build\x64\vc12\bin\pos\003.jpg 1 0 0 400 400
  C:\opencv\build\x64\vc12\bin\pos\004.jpg 1 0 0 400 400
  ……

  座標はスペース区切りで正解画像の切り抜き範囲を表しており、[個数] [左上x座標] [左上
  y座標] [横幅] [高さ]から成ります。1枚の画像内に2枚の正解画像を設けるときは、[個数]
  が2となり、1つめの[左上x座標] [左上y座標] [横幅] [高さ]に続いて、2つめの[左上x座
  標] [左上y座標] [横幅] [高さ]を表記します。
  今回は全て切り抜き済みなので、すべて「1 0 0 400 400」でいいというわけです。

 4.ネガティブ画像の一覧を作成
  ネガティブ画像の一覧もコマンドプロンプトを使って一括出力します。座標情報は不要
  ですが、相対パスで記述しなくてはならないため手直しします。

  (置換前) neglist.txt
  C:\opencv\build\x64\vc12\bin\neg\000.jpg
  C:\opencv\build\x64\vc12\bin\neg\001.jpg
  C:\opencv\build\x64\vc12\bin\neg\002.jpg
  C:\opencv\build\x64\vc12\bin\neg\003.jpg
  C:\opencv\build\x64\vc12\bin\neg\004.jpg
  ……
  (置換後) [C:\opencv\build\x64\vc12\bin]→[.]
  .\neg\000.jpg
  .\neg\001.jpg
  .\neg\002.jpg
  .\neg\003.jpg
  .\neg\004.jpg

 5.ポジティブ画像を1つのベクトルデータに変換
  ポジティブ画像は1つのデータにまとめておく必要があります。ここで付属ツールのひとつ
  である「opencv_createsamples」が登場します。
  opencv_createsamples.exe -info poslist.txt -vec ./vec/pos.vec -num 600 -w 400 -h 400

  -info では先ほど作成したポジティブ画像の一覧データを指定します。
  -vec ではベクトルデータの保存先とファイル名を指定します。
     フォルダは自動作成されないため、あらかじめ作らないとエラーになります。
  -num はポジティブ画像の数(一覧データの行数)です。
  -w -h はそれぞれ画像の横幅と高さを表しています。

  作成に成功すると、指定した保存先にvecファイルが生成されます。

(5.5.ベクトルデータをまとめる)
   実は、「opencv_createsamples」には1つのサンプルから傾きなどが異なる複数のサ
  ンプルを作り出してvecファイルにまとめる機能もあります。
   この機能を使うと、5枚のサンプルでも各100枚のデータを合成することで500枚分相
  当のサンプルを得ることができるのですが、1つにまとめなければならないvecファイル
  が5つ生成されてしまうため、合成作業が必要になります。
   合成作業にはmergevecが便利ですが、python版しか見当たりませんでした。もし使
  う場合はpythonを実行できる環境も必要です。
  python mergevec.py -v ./vec -o ./vec/pos.vec
   当初はこの機能で約1万枚分のサンプルを取りましたが、結果から言うと現実の600枚
  にはかないませんでした。用途によってはアリなのかもしれませんが、今回はお蔵入り。

 6.学習データの作成
   学習データは、これまた付属のツールを使ってゆっくり作成していきます。サンプル
  の数や精度により所要時間は異なりますが、中断・再開も可能です。numPosはポジテ
  ィブ画像の数、numNegはネガティブ画像の数ですが、ポジティブ画像の数は実際の8
  割〜9割程度にしておかなければならないとか。(480=600x0.8)
  opencv_traincascade.exe -data ./cascade/ -vec ./vec/pos.vec -bg ./neglist.txt -numStages 10 -numPos 480 -numNeg 1000

  数値などは違いますが、学習中の様子はこのような感じ。
イメージ 3

 放っておくと、いつの間にか「Train dataset for temp stage can not be filled. Branch training terminated.」となって終了し、指定の保存先に学習ファイルである「cascade.xml」が生成されます。学習ステージが多ければ多いほど厳格な分類器ということになりますが、多すぎるとかえって検出できなくなることも。実装側でも調整パラメータがあるので、ほどよいところでやめにします。

 では実際にE233系の"顔認証"をしてみましょう。C#で開発するため、OpenCVのラッパー「OpenCvSharp」を使います。バージョンはOpenCV本体にあわせて2.4.10。dllの類は実行ファイルのある場所に移しておく必要があるらしく、片っ端からコピーしました。[参考3]
private void CascadeFinder(string cascade_file, string path) {
// カスケード分類器の準備
        Mat image = new Mat(path);
        CascadeClassifier cascade = new CascadeClassifier();
        cascade.Load(cascade_file);
        Rect[] faces = cascade.DetectMultiScale(image, 1.1, 3, 0, new OpenCvSharp.CPlusPlus.Size(image.Height / 18, image.Height / 18));
// 顔候補のうち面積の一番大きいものをマークする
        int t = 0, tmp = 0;
        for (int i = 0; i < faces.Length; i++) {
                if (faces[i].Width > tmp) {
                    tmp = faces[i].Width;
                    t = i;
}
}
        // 顔の枠を描画する
        Cv2.Rectangle(image,
                    new OpenCvSharp.CPlusPlus.Point(faces[t].X, faces[t].Y),
                    new OpenCvSharp.CPlusPlus.Point(faces[t].X + faces[t].Width, faces[t].Y + faces[t].Height),
                    new OpenCvSharp.CPlusPlus.Scalar(0, 255, 0), image.Height / 240, LineType.AntiAlias);  
// 基本画像のリサイズ
float height = image.Height;
float width = image.Width;
float resize = 0;
if (image.Height >= image.Width) { // 縦長画像の場合
                resize = width * (400 / height);
                Cv2.Resize(image, image, new CvSize((int)resize, 400), 0, 0, Interpolation.Cubic);
} else { // 横長画像の場合
resize = height * (600 / width);
                Cv2.Resize(image, image, new CvSize(600, (int)resize), 0, 0, Interpolation.Cubic);
}
// pictureBox1に表示
pictureBox1.Image = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(image);
image.Dispose();
 }
 cascade.DetectMultiScaleが検出部分で、引数には検出精度の設定項目が含まれます。第5引数と第6引数にそれぞれ検出最小サイズ・検出最大サイズが指定できます。編成写真では全体の面積に占める電車の顔の割合が大きくなるため、小さい領域の検知は誤検知である可能性が高いです。しかし、入力画像の大きさは不定なので、画像の高さを基準にして最小サイズを設定しています。同じ理由で、直後のfor文では検出領域のうち最も大きいものを取り出すようにしています。
 実際にプログラムを動かしてみると、先頭の顔部分が緑枠で囲われており、一応検出には成功した様子。
イメージ 4

 しかしよく考えると自分の撮影データを学習に使っているんだから、自分の写真で検出できるのは当然かも……ということで、Googleで横浜線E233系の編成写真をかき集めて実験。スクショは載せられませんが、100枚中80枚成功という結果でした。
……横浜線の写真全然なくて、100枚集めるのがめちゃくちゃ大変でした。

 問題はここから。
 顔を検出できても、編成番号が分からなければ意味がありません。例によって番号は小さいので、検出した顔を9分割し、編成番号が含まれる部分をトリミングすることにしました。
イメージ 5

 リサイズ前の元画像から、4番部分をトリミングして編成番号を探そうというものです。これぐらい範囲が狭まれば、編成番号の場所も機械学習で当てられるだろうと調子づきます。バカのひとつ覚えで、今度は編成番号の学習データを作りました。その数530個。
イメージ 6

 早速機械学習させてみますが、学習ステージが3から先に進みません。これは、「学習用データが悪い」ことにほかなりません。しかし、一応cascade.xmlは生成されているため、ものは試しと実践してみます。

イメージ 7

 ……これはダメだ。
 車両前面のように「大きいところ」とはいかないので、条件に合った検出部分は全て描画させましたが、列番やYokohama Lineのロゴが検出されてしまっています。一見すると、y座標が一番高い枠を読めばいいように思えますが、他の画像ではガラス内の映り込みや光の反射も編成番号として検出してしまっており、どうしようもありません。
 誤検出部分を中心に新たにネガティブ画像を量産し、再度学習させてみましたが、多少マシという程度で使い物にはなりませんでした。

 そこで、より特徴的な点からの相対距離で検出することを考えます。
 というのも、顔の位置や角度などは写真によって様々ですが、中のパーツ自体は動かないため、「絶対に見つけられる点」からの相対的な距離で検出すれば間違いなく編成番号を当てられるだろう、ということです。トリミング済みの範囲で一番特徴的な点は……
イメージ 8

こ こ

 というわけで白・黒・黄緑の3色が分かれるポイントを基準点とする方針に。流石にこれも600個、というのは疲れるので100個くらいで訓練させてみました。
 同時並行で画像をテキスト化するOCRを準備。TesseractというOCRが便利そうだったのでNuGetから導入しました。検出に必要なのは「H」と「0〜9」だけなので、精度を高めるためにもその旨を記述。
private void FormationOCR(Bitmap image) {
// オブジェクト定義
var tesseract = new Tesseract.TesseractEngine(@"C:\tessdata", "eng");
tesseract.SetVariable("tessedit_char_whitelist", "H1234567890");
// 画像呼出
        var img = new System.Drawing.Bitmap(image);
        // OCRの実行と表示
        var page = tesseract.Process(img);
        // 内容による分岐
        string formation = "";
        formation = page.GetText().Replace(" ","").Replace("H", "").Replace("00","0");
        try {
        int f = 0;
        f = int.Parse(formation);
        comboBox1.SelectedIndex = f;
        } catch {
comboBox1.SelectedIndex = 0;
}
}
 TesseractEngineでは学習データファイルのあるディレクトリを指定し、第2引数で言語を指定しています。データはこちらからダウンロード可能。ここでは最終的にH---,H001〜H028が書かれたコンボボックスの値を選択しています。さて、これでどうか……

イメージ 9

イメージ 10

やりました!

 撮影日時はExifデータの0x9003を読んで変換しているだけで、保存名はこれまでの自分の保存ルールにのっとって「YYYYMMDD_HHMM(H***)」になっています。編成は主動による指定および修正も可能な仕様です。
 もう少し他の写真でも実験。夜間の写真は読取精度が落ちますが、意外と読めているものもありました。本当はそれこそOpenCVで画像処理してOCRに突っ込んであげるべきなんでしょうが、とりあえず読めてはいるので様子見です。
イメージ 11

 こちらは曇天。イメージ 12

 顔の検出精度は7〜8割程度。顔が見つからないと編成番号は当然見つかりません。イメージ 13

 基準点は学習用データが足りていないのでまだまだ未熟で、たまに車体側面を拾ってしまうようです。そうすると当然編成番号の枠もずれてしまいます。
イメージ 14

 とりあえず、思い付きの企画もどうにかひと段落ということで、手元の画像200枚を使って編成の判定ができるかを試してみました。
イメージ 15


 結果はご覧のとおり。正面検出率は先述の通り7割程度ですが、その中で編成の判定ができたのは4割未満と課題の残る結果です。「判定失敗」には、そもそも基準点が特定できず編成番号が発見できないものと、編成番号の枠がずれてOCR処理をしても結果が得られなかったもの、枠は正しい位置にあったがOCR処理が上手くいかなかったものが含まれます。
 相対位置による編成番号の枠決定については、顔として判定される枠の大きさや編成自体の傾きを考慮に入れてはいるものの、まだ詰めが足りておらず、位置がずれてしまう写真も多々あるのが現状です。今後は、枠の位置をより正確にすること、そして傾き補正などを施すことでOCRの読取精度を高めていきたいところです。

 ところで、機械学習用の画像を大量に用意したり、ああでもないこうでもないと試行錯誤したり、やっぱりファイル名を1つずつ地道に手で打った方が早かったんじゃない?という点についてはノーコメントということでお願いします。

本文はここまでですこのページの先頭へ
みんなの更新記事