Mugichoko's blog

Mugichoko’s blog

プログラミングを中心としたメモ書き.

Caffeを通してCNNを理解する #3

大域的目標

本シリーズの目標はCNNのことを全く知らない人間が少し理解することにある.前回までの取り組みは以下のリンクより.

mugichoko.hatenablog.com

今回の目標

  1. 自分で学習用データのLMDBを作成する
  2. 自作のLMDBを使って学習及び認識までを通しで行う

0. LMDBの作成方法の模索

create_mnist.shを覗く

まず,何をどうすればいいのかを把握するために,MNISTのLMDBを構築するために使ったCAFFE_ROOT/examples/mnist/create_mnist.shを開いてみる.基本的にはパスをセットして,いらないデータを削除した後にconvert_mnist_data.exetrain用データセットtest用データセットのそれぞれを入力として起動しているだけの様子.

create_mnist.sh

#!/usr/bin/env sh
# This script converts the mnist data into lmdb/leveldb format,
# depending on the value assigned to $BACKEND.
set -e

EXAMPLE=examples/mnist
DATA=data/mnist
BUILD=build/examples/mnist

BACKEND="lmdb"

echo "Creating ${BACKEND}..."

rm -rf $EXAMPLE/mnist_train_${BACKEND}
rm -rf $EXAMPLE/mnist_test_${BACKEND}

$BUILD/Release/convert_mnist_data.exe $DATA/train-images.idx3-ubyte \
  $DATA/train-labels.idx1-ubyte $EXAMPLE/mnist_train_${BACKEND} --backend=${BACKEND}
$BUILD/Release/convert_mnist_data.exe $DATA/t10k-images.idx3-ubyte \
  $DATA/t10k-labels.idx1-ubyte $EXAMPLE/mnist_test_${BACKEND} --backend=${BACKEND}

echo "Done."

create_mnist_data.cppを覗く

CAFFE_ROOT/examples/mnist/create_mnist_data.cppを開いてみる.GFLAGSやGLOGをよく知らないので,う~んややこしい,ように見える.でも,データセットを作成する上で重要そうなのは,どうやら79行目~115行目にあるようだ.それより前は,バイナリデータを扱うための処理の様子.以下,その抜粋及び自分の理解をコメントで付記したもの.

// データセットのインスタンス化.
// "db_backend" は "create_mnist.sh" の引数で "lmdb" と与えられている.
scoped_ptr<db::DB> db(db::GetDB(db_backend));
// "db_path" も同様に引数で与えられている.
// 具体的には "$EXAMPLE/mnist_train_${BACKEND}" (もしくは,train→test)の部分が入力されている.
// つまり, "$EXAMPLE/mnist_train_lmdb" (もしくは,train→test)というフォルダ(データベース入り)を作る.
db->Open(db_path, db::NEW);
// 新しくデータセット作るよ,ということらしい.
// scoped_ptrはboostライブラリにある,リソースが使われなくなったら勝手に開放するポインタらしい.
scoped_ptr<db::Transaction> txn(db->NewTransaction());

// Storing to db ← dbにデータを格納していくよ,と書いてある
char label; // ラベル.つまり,MNISTの場合0~9が入るっぽい
char* pixels = new char[rows * cols]; // どっからどう見ても1チャンネル画像データ
int count = 0;
string value;

// データ形式を定義
Datum datum;  // Caffeのデータ形式,そこに...
datum.set_channels(1);  // 1チャンネルの画像を入れる
datum.set_height(rows); // 画像の高さ
datum.set_width(cols);  // 画像の幅を入れる
// 単なるログの記録
LOG(INFO) << "A total of " << num_items << " items.";
LOG(INFO) << "Rows: " << rows << " Cols: " << cols;
// ここから実際の "datum" へのデータ格納が始まる.
// 尚,データの数 "num_items" は取得済み(69行目)
for (int item_id = 0; item_id < num_items; ++item_id) {
  // 画像データとラベル(正解値)をファイルから読み込む
  image_file.read(pixels, rows * cols);
  label_file.read(&label, 1);
  // 画像と対応するラベルを "datum" へ格納
  datum.set_data(pixels, rows*cols);
  datum.set_label(label);
  // 今,何番目のデータを処理しているっけ?
  string key_str = caffe::format_int(item_id, 8);
  // 格納したデータから,1連なりの文字列データを取得
  datum.SerializeToString(&value);
  // それを,データベース全体に格納?
  txn->Put(key_str, value);

  // 1000コ処理したら,実際にディスクに書き込んでいる?
  if (++count % 1000 == 0) {
    txn->Commit();
  }
}
// write the last batch ← やっぱりそうだ!最後に書き込む
if (count % 1000 != 0) {
    txn->Commit();
}
// ログを出力したら,画像用メモリを解放
LOG(INFO) << "Processed " << count << " files.";
delete[] pixels;
// dbを閉じて,書き込み完了!
db->Close();

1. 自分で学習用データのLMDBを作成する

データの準備

MNISTを模して,train及びtest用の画像とラベルの対を用意することにした.これはC++で書いた.

Main.cpp

ここでは,単に,train及びtest用に関数createDataset()を呼び出しているだけ.

#include <iostream>

#include "DatasetGenerator.h"

void main()
{
    const int imgSize = 28;
    const int numItems = 50000;
    const float numTrainTestRatio = 0.7f;
    const int numTrainItems = int(numItems * numTrainTestRatio);
    const int numTestItems = int(numItems * (1.0f - numTrainTestRatio));

    if (!createDataset("train", imgSize, numTrainItems))
    {
        std::cerr << "ERR: Failed to create training database" << std::endl;
        exit(EXIT_FAILURE);
    }

    if (!createDataset("test", imgSize, numTestItems))
    {
        std::cerr << "ERR: Failed to create testing database" << std::endl;
        exit(EXIT_FAILURE);
    }
}

DatasetGenerator.h

以下の組み合わせで生成される0~9の画像を自動生成する.

  1. ランダムに明るさの底上げ(66行目)
  2. ランダムにフォントを選択(67行目)
  3. ランダムに数画素分位置をずらして画像に書き込み(69行目)
  4. ランダムに各画素にノイズを追加(71~74行目)
  5. ランダムな確率で画像にぼかしを付与(76~79行目)

後は,ラベル情報(0~9の数値)と共に,画像の名前をテキストに書き込んで保存する.

#pragma once

#include <opencv2/opencv.hpp>
#include <fstream>
#include <direct.h>
#include <iomanip>

double getFontScale(cv::HersheyFonts fontType)
{
    double scale = 1.0;

    switch (fontType)
    {
    case cv::FONT_HERSHEY_PLAIN:
        scale = 2.0;
        break;
    case cv::FONT_HERSHEY_COMPLEX_SMALL:
        scale = 1.8;
        break;
    default:
        break;
    }

    return scale;
}

bool createDataset(
    const std::string &dbPath = "my_lmdb",
    int imageSize = 28,
    int numItems = 50000
)
{
    std::cout << "Creating " << dbPath << "..." << std::endl;

    std::string listName(dbPath + "/list.txt");
    std::ofstream dbItemList(listName.c_str());

    if (!dbItemList.is_open())
    {
        if (_mkdir(dbPath.c_str()) == 0)
        {
            std::cout << "Created " << dbPath << " folder" << std::endl;
        }
        else
        {
            std::cerr << "ERR: Failed to create " << dbPath << std::endl;
            return false;
        }

        dbItemList.open(listName.c_str());
        if (!dbItemList.is_open())
        {
            std::cerr << "ERR: Failed to create " << listName << std::endl;
            return false;
        }
    }
    
    dbItemList << numItems << std::endl;
    dbItemList << imageSize << " " << imageSize << " " << 1 << std::endl;
    for (int idx = 0; idx < numItems; ++idx)
    {
        std::string digit = std::to_string(idx % 10);
        cv::Mat img(imageSize, imageSize, CV_8U);

        // add bias
        img = rand() % 100;
        int fontType = rand() % 8;
        double fontScale = getFontScale(cv::HersheyFonts(fontType));
        cv::putText(img, digit, cv::Point(2 + rand() % 4, 22 + rand() % 4), fontType, fontScale, cv::Scalar(255));
        // add noise
        img.forEach<uchar>([](uchar &p, const int *pos) -> void
        {
            p = std::min((p + (rand() % 150)), 255);
        });
        // add blur
        if (rand() % 2)
        {
            cv::blur(img, img, cv::Size(3, 3));
        }

        std::stringstream imgPath("");
        imgPath << dbPath << "/img_" << std::setw(8) << std::setfill('0') << idx << ".bmp";
        cv::imwrite(imgPath.str(), img);

        dbItemList << digit << " " << imgPath.str() << std::endl;
    }

    std::cout << "Created " << dbPath << "" << std::endl;

    return true;
}

この処理が終わると以下のような構成の,2つのフォルダが生成される.

  • train
    • list.txt
    • 画像群 (.bmp)
  • test
    • list.txt
    • 画像群 (.bmp)

具体的な中身は以下の様になっている.

f:id:Mugichoko:20171130222128p:plain

15000
28 28 1
0 test/img_00000000.bmp
1 test/img_00000001.bmp
2 test/img_00000002.bmp

(以下省略)

LMDBの作成

create_mnist_data.cppをビルドしようと思ったのだが,どうも依存関係が多すぎて面倒だ.ということで,PythonでLMDB変換用プログラムを書くことにした.

LMDBのインストール

まずは,LMDBをインストールする必要があるが,Anacondaを使えば conda install -c conda-forge python-lmdbと,コマンドプロンプトで打てばよいだけなので,とても簡単だ.(参考:Python Lmdb :: Anaconda Cloud

LMDB作成用Pythonスクリプト

ここのWebサイトを参考に作成した.create_mnist_data.cppとよく似ている.

ConvertDataset.py

途中の容量確保のところで10というマジックナンバーが出てくるが,謎である.ここに書いてあることを読めば理解できるのかな?とも思うが,面倒なので,とりあえずほおっておく.

import caffe
import lmdb
import numpy as np
from PIL import Image
import sys

fileName = "test/list.txt"
dbName = "my_test_lmdb"

with open(fileName, "r") as file:
    numItems = file.readline()
    width, height, channels = file.readline().split(" ")

    numItems = int(numItems)
    width = int(width)
    height = int(height)
    channels = int(channels)

    maxSize = np.zeros((numItems, channels, width, height), dtype=np.uint8).nbytes * 10
    env = lmdb.open(dbName, map_size = maxSize)

    with env.begin(write = True) as txn:
        for idx, line in enumerate(file):
            label, path = line.split(" ")
            
            img = np.array(Image.open(path.rstrip()))
            
            datum = caffe.proto.caffe_pb2.Datum()
            datum.width = width
            datum.height = height
            datum.channels = channels
            datum.data = img.tobytes()
            datum.label = int(label)

            sys.stdout.write("\ridx: {}".format(idx))

            strID = '{:08}'.format(idx)
            txn.put(strID.encode('ascii'), datum.SerializeToString())

print("\n")

env.close()

これを以下の通り実行する.

  1. ConvertDataset.pyと同一フォルダ内にtrainフォルダとtestフォルダを置く
  2. コマンドプロンプトからpython ConvertDataset.pyで実行
  3. my_train_lmdb及びmy_test_lmdbフォルダが生成されていることを確認

2. 自作のLMDBを使って学習及び認識までを通しで行う

基本的な流れは以下の通り.

  1. 必要なファイルを%CAFFE_ROOT%\mycnnに置く
    • my_train_lmdbフォルダとmy_test_lmdbフォルダ
    • solver.prototxttrain.prototxtdeploy.prototxt
      • 参考:前回記事
      • ただし,train.prototxt内で指定している入力ファイル名は今回のものに変更する!
    • classify_digits.y
    • 認識させたい画像データ
      • 今回は,前回と同じものを用いる
      • 参考:前回記事
  2. 学習開始
    • ..\build\tools\Release\caffe.exe train --solver=solver.prototxt
  3. 以下のファイルが生成されたか確認
    • _iter_5000.caffemodel
    • _iter_5000.solverstate
  4. 学習結果を用いて認識処理を実行
    • python classify_digits.py 7.pngの様に実行する
  5. 結果が得られる
    • 以下は,前回と同じく6,pngを認識させた結果
C:\Libraries\caffe\mycnn> python classify_digits.py 6.png
(中略)
I1130 14:45:17.019353  6984 net.cpp:744] Ignoring source layer ip2_ip2_0_split
I1130 14:45:17.019353  6984 net.cpp:744] Ignoring source layer accuracy
C:\Users\Shohei\Anaconda3\lib\site-packages\skimage\transform\_warps.py:84: UserWarning: The default mode, 'constant', will be changed to 'reflect' in skimage 0.15.
  warn("The default mode, 'constant', will be changed to 'reflect' in "
prediction shape: (500,)
prediction shape: 6

10枚の画像の結果を見ただけなので短絡的な見解だが,6や9を8と間違わないので,MNISTで学習させた結果よりも良さそうだ(前回記事参照).というよりは,自身の手書き文字が,MNISTの手書き文字よりもフォントに近いのだと思われる.

まとめ

自作のデータセットを作成し,Caffeを使って学習させ,自作の入力画像を用いてCaffeから認識結果を得ることができた!この作業を通して,CNNの基本を理解したつもりだ.

次の目標を設定するとすれば,論文に書いてあるネットワークを実際に自分で作ってみることかな?と思う.