自作データセットをアノテーションしてEfficientDetで物体検知するまで 〜キャラクター顔検知〜

# 何をやったの？

* 既存のアノテーションツールを使って物体検知の自作データセットを作成した。
* アノテーションツールの出力をCOCOデータセットのフォーマットに変換するコードを書いた。
* (数多の)EfficientDetの実装から使いやすそうなものを見つけて学習が回ることを確認した。
* 実際にイラスト中のキャラクター顔検知データセットを作成し、学習済みモデルのデモを作った。

# デモ

この記事でメモした方法を使って作成したデータセットによる学習済みのEfficientDetモデルは[Githubレポジトリ](https://github.com/kosuke1701/pretrained-efficientdet-character-head)からダウンロードできます。

Google colaboratoryで学習済みモデルを試せる[デモ](https://colab.research.google.com/drive/1zabpphmfq2P5kTBV7WiZOz-VOYJLx89S?usp=sharing)も用意したので遊んでみてください。

pipでインストールできるモジュールにしました。事前学習済みのモデルを試すことができます。 [AnimeCV](https://github.com/kosuke1701/AnimeCV)

## 背景（読み飛ばしOK）

最近ふと開発している[キャラクターイラスト自動分類ツール](https://qiita.com/kosuke1701/items/70619944d86c1cab1eaf)の精度向上のためイラスト中の顔検知がやりたくなりました。

アニメの顔検知では、これまで[OpenCVを使っている取り組み](https://qiita.com/mczkzk/items/fda37ac4f9ddab2d7f45)や[YOLOを使っている取り組み](https://qiita.com/hibino114514/items/054ee8266d5b9e0c704e)が記事として公開されています。
ただOpenCVのモデルではやはり精度に限度があるらしく、またYOLOの記事の方は学習済みのモデルが公開されていないようです（公開されていたらごめんなさい）。

そこで今回は自分で物体検知モデルを学習してやることにしました。ただデータセットなども無いので、今回は自分でアノテーションからやっていきます。

# データ準備
## アノテーション

物体検知は比較的メジャーな研究分野なので、アノテーションのためのツールも多く公開されているようです。

今回はこれらのツールの[比較記事](https://qiita.com/shu-yusa/items/d19ea57e3cf9c4dbdce2)を参考に、[LabelImg](https://github.com/tzutalin/labelImg)を使ってアノテーションを行いました。

**追記(2020/11/09)：**
dockerが利用できないと使いづらいアプリケーションですが、[coco-annotator](https://github.com/jsbroks/coco-annotator)というツールを用いると直接COCOフォーマットのデータセットが出力できて便利です。GUIも直感的でかなり使いやすいですね。
目的に応じてはこちらを利用した方が良いかもしれません。

### LabelImgの使い方

アノテーションのための準備はとても簡単で、必要な環境を整えた後はアノテーション対象のクラス定義のファイル `classes.txt` を用意し、アノテーションする画像を１つのディレクトリ(例えば `image_dir`) にまとめておくだけです。

クラス定義ファイルは各行にクラス名を書いただけの簡単なもので、例えば今回の場合は「顔」を検知したいだけなので以下のようになります。

```classes.txt
face
```

アノテーションツールを起動するには以下のようにします。

```
python labelImg.py image_dir classes.txt
```

ツールのGUIは極めて直感的で、`w`キーでBouding Boxの作成を開始した後は対角線の両端に相当する点をクリックするだけです。

### ショートカット

このツールには様々な便利なショートカットが用意されていますが、よく使ったのは以下のキーでした。

| キー | 機能 |
|---|---|
| w | 新しいBounding Boxの作成を開始する。 |
| d | 次の画像に移る。 |
| a | 一個前の画像に移る。 |
| Ctrl+S | アノテーション結果を保存。 |

### 補足

* アノテーション結果はPascalVOCとYOLOの2通りのフォーマットで保存できます。(デフォルトはPascalVOCです。)

* 管理のしやすさから、私はアノテーション結果のxmlファイルは画像と同じディレクトリに保存しました。そのおかげか、一度ツールを閉じてアノテーションを再開すると以前のアノテーション結果が自動的に読み込まれていました。

## データ前処理(COCOフォーマットへの変換)

最近の物体検知の研究ではCOCO2017データセットなどがベンチマークとしてよく用いられており、論文の公開実装でもこのフォーマットを入力するものが多くなっているようです。

そこで、今回はLabelImgでアノテーションした結果をCOCOフォーマットに変換する前処理コードを書きました。前処理コードは[Github](https://github.com/kosuke1701/labelimg-coco-preprocess)で公開しています。

なおこの前処理コードの作成にあたり、具体的なフォーマットについては[こちらの記事](https://qiita.com/harmegiddo/items/da131ae5bcddbbbde41f)を参考にしました。より詳細なフォーマットなどが気になる方はご参照ください。

### データセットの階層構造

最終的なデータセットのファイル構成は以下のようになります。`project_name`は自由な名前に読み替えてください。

```
project_name
+- train
|  +- <訓練画像>
+- val
|  +- <評価用画像>
+- annotations
   +- instances_train.json
   +- instances_val.json
```

### 前処理コード実行例

前処理コードを実行するには以下のコマンドを実行してください。以下では`<DIR1>`などのディレクトリ以下にLabelImgによるアノテーション結果のXMLファイルが保存されているとしています。（こちらのディレクトリは空白区切りで複数選択が可能です。）

```
# 訓練データ
python convert_to_coco.py \
    --image-root-dir project_name/train \
    --annotation-fn project_name/annotations/instances_train.json \
    --copy-images --same-dir \
    --annotation-dir <DIR1> <DIR2> ...

# 評価データ（trainをvalに変えるだけです）
python convert_to_coco.py \
    --image-root-dir project_name/val \
    --annotation-fn project_name/annotations/instances_val.json \
    --copy-images --same-dir \
    --annotation-dir <DIR1'> <DIR2'> ...
```

なお、前提として以下を仮定しています：

* 画像と同じディレクトリにPascalVOCフォーマットでアノテーション情報のXMLファイルを保存している。

# EfficientDetの学習

EfficientDetの実装を公開しているレポジトリはいくつかあります。いろいろと調べてみた結果[ zylo117 /
Yet-Another-EfficientDet-Pytorch ](https://github.com/zylo117/Yet-Another-EfficientDet-Pytorch)が使いやすそうだったので今回はこちらを利用してモデルを学習していきます。

## 準備

自作データセットでの学習を行うには、環境の準備の他に、①事前学習済みのパラメータのダウンロードと、②自作データセットの定義ファイル作成、を行う必要があります。

### 事前学習済みパラメータ

EfficientDetの事前学習済みパラメータは[Githubレポジトリ](https://github.com/zylo117/Yet-Another-EfficientDet-Pytorch#pretrained-weights-and-benchmark)から'efficientdet-d*.pth'をダウンロードします。

`d0`や`d7`は（おそらく）[論文](https://arxiv.org/abs/1911.09070)に記載されているモデルのサイズなどに関する設定に対応しており、数字が小さいほど軽量な代わりに精度が低いモデルとなっているようです。

今回はひとまず`efficientdet-d0.pth`をダウンロードしました。

### データセット定義ファイル

自作データセットで学習するには`projects`ディレクトリ以下に設定のymlファイルを作成する必要があるようです。

COCOデータセットの設定から変更する必要がある項目はデータセットの名前である`project_name`と、検知するクラス名のリストである`obj_list`、あとは利用するGPUの個数である`num_gpus`あたりでしょう。

`anchors_scales`や`anchors_ratios`については、私自身まだ物体検知やEfficientDetの細かいアルゴリズムを理解していないため今回はCOCOデータセットのままとしました。

私の場合、定義ファイルは以下のようになりました。

```projects/coco_pixiv.yml
project_name: coco_pixiv  # also the folder name of the dataset that under data_path folder
train_set: train
val_set: val
num_gpus: 1

# mean and std in RGB order, actually this part should remain unchanged as long as your dataset is similar to coco.
mean: [0.485, 0.456, 0.406]
std: [0.229, 0.224, 0.225]

# this is coco anchors, change it if necessary
anchors_scales: '[2 ** 0, 2 ** (1.0 / 3.0), 2 ** (2.0 / 3.0)]'
anchors_ratios: '[(1.0, 1.0), (1.4, 0.7), (0.7, 1.4)]'

# must match your dataset's category_id.
# category_id is one_indexed
obj_list: ['face']
```

### `anchor_ratios`の設定  (2020/11/09追記)

EfficientDetはアンカーを用いて物体検知で行うため、自作データセットに細長い物体が含まれる場合にはデフォルト設定ではうまく検知ができない場合があります。

そのような場合には[KMeansを使ったツール](https://github.com/mnslarcher/kmeans-anchors-ratios)を用いて自作データセットに対してより適切なアンカー形状を決めることができるようです。

ツールの使い方は簡単で、gitでレポジトリをクローンした際にルートディレクトリにある`kmeans_anchors_ratios.py`を以下のように用いるだけです。

* ただし依存関係で`tqdm`モジュールのインストールが必要です。

```shell
python kmeans_anchors_ratios.py \
  --instances <COCOフォーマットのアノテーションJSONファイル> \
  --num-runs 3 \ # K-Meansを回す回数
  --num-anchors-ratios 5 \ # アンカー形状の個数。3とかでも良いと思います
  --input-size 512 \ # モデル入力前に画像がリサイズされる大きさ
  --anchor-sizes 32 64 128 256 512 # アンカーサイズのリスト
```

実行すると、出力において`K-Means anchors ratios:`と示されている行に学習されたアンカー形状が表示されます。

## 学習

学習を実行するには以下を実行します。ハイパーパラメータなどは特に根拠があるわけではないので参考程度に。

```
python train.py -c 0 -p project_name \
    --batch_size 8 --lr 1e-5 --num_epochs 100 \
    --load_weights efficientdet-d0.pth \
    --data_path <前処理コードで作成したデータセットディレクトリproject_nameがあるディレクトリ>
```

`-c`オプションで指定しているのは前述した`d0`とか`d7`に対応する数値です。なので、この数値と`--load_weights`で指定する事前学習済みパラメータの方の`d*`の数値は一致している必要があります。

GTX1070で、学習データ数900個程度、epoch数100の学習は1時間程度かかりました。

## 評価

学習したモデルを実際に画像に適用して性能がどの程度になるかを見るには`efficientdet_test.py`を実行します。
ただし、このコードは入力画像名などがベタ書きになっているので以下のように変更すると

```
python efficientdet_test.py <d*に対応する数字> <モデル.pthファイル名> <画像名>
```

で実際の検知結果を可視化することができます。**表示された画像のウィンドウを消すには何かキーを押してください。xボタンで消すとフリーズすることがあります。**

### 評価コード修正例

```efficientdet_test.py

# Author: Zylo117

"""
Simple Inference Script of EfficientDet-Pytorch
"""
import sys
import time
import torch
from torch.backends import cudnn
from matplotlib import colors

from backbone import EfficientDetBackbone
import cv2
import numpy as np

from efficientdet.utils import BBoxTransform, ClipBoxes
from utils.utils import preprocess, invert_affine, postprocess, STANDARD_COLORS, standard_to_bgr, get_index_label, plot_one_box

compound_coef = int(sys.argv[1])
force_input_size = None  # set None to use default size
img_path = sys.argv[3]

# replace this part with your project's anchor config
anchor_ratios = [(1.0, 1.0), (1.4, 0.7), (0.7, 1.4)]
anchor_scales = [2 ** 0, 2 ** (1.0 / 3.0), 2 ** (2.0 / 3.0)]

threshold = 0.2
iou_threshold = 0.2

use_cuda = True
use_float16 = False
cudnn.fastest = True
cudnn.benchmark = True

obj_list = ['face']


color_list = standard_to_bgr(STANDARD_COLORS)
# tf bilinear interpolation is different from any other's, just make do
input_sizes = [512, 640, 768, 896, 1024, 1280, 1280, 1536]
input_size = input_sizes[compound_coef] if force_input_size is None else force_input_size
ori_imgs, framed_imgs, framed_metas = preprocess(img_path, max_size=input_size)

if use_cuda:
    x = torch.stack([torch.from_numpy(fi).cuda() for fi in framed_imgs], 0)
else:
    x = torch.stack([torch.from_numpy(fi) for fi in framed_imgs], 0)

x = x.to(torch.float32 if not use_float16 else torch.float16).permute(0, 3, 1, 2)

model = EfficientDetBackbone(compound_coef=compound_coef, num_classes=len(obj_list),
                             ratios=anchor_ratios, scales=anchor_scales)
model.load_state_dict(torch.load(sys.argv[2]))
model.requires_grad_(False)
model.eval()

if use_cuda:
    model = model.cuda()
if use_float16:
    model = model.half()

with torch.no_grad():
    features, regression, classification, anchors = model(x)

    regressBoxes = BBoxTransform()
    clipBoxes = ClipBoxes()

    out = postprocess(x,
                      anchors, regression, classification,
                      regressBoxes, clipBoxes,
                      threshold, iou_threshold)

def display(preds, imgs, imshow=True, imwrite=False):
    for i in range(len(imgs)):
        if len(preds[i]['rois']) == 0:
            continue

        for j in range(len(preds[i]['rois'])):
            x1, y1, x2, y2 = preds[i]['rois'][j].astype(np.int)
            obj = obj_list[preds[i]['class_ids'][j]]
            score = float(preds[i]['scores'][j])
            plot_one_box(imgs[i], [x1, y1, x2, y2], label=obj,score=score,color=color_list[get_index_label(obj, obj_list)])


        if imshow:
            cv2.imshow('img', imgs[i])
            cv2.waitKey(0)

        if imwrite:
            cv2.imwrite(f'test/img_inferred_d{compound_coef}_this_repo_{i}.jpg', imgs[i])


out = invert_affine(framed_metas, out)
display(out, ori_imgs, imshow=True, imwrite=True)

print('running speed test...')
with torch.no_grad():
    print('test1: model inferring and postprocessing')
    print('inferring image for 10 times...')
    t1 = time.time()
    for _ in range(10):
        _, regression, classification, anchors = model(x)

        out = postprocess(x,
                          anchors, regression, classification,
                          regressBoxes, clipBoxes,
                          threshold, iou_threshold)
        out = invert_affine(framed_metas, out)

    t2 = time.time()
    tact_time = (t2 - t1) / 10
    print(f'{tact_time} seconds, {1 / tact_time} FPS, @batch_size 1')
```

# 最後に

今回は物体検知データセットのアノテーションからモデルの学習まで一通りやってみました。
まだ1000枚程度しかアノテーションしていないのですが、学習結果のモデルの性能を見ると思ったよりも実用的なレベルまで学習ができているようです。学習自体も（きちんと収束まではできていませんが）１時間程度と案外お手軽にできるレベルだったので助かりました。

今後はアノテーションの枚数を増やして本来の目的であるイラスト分類のツールの方の精度改善につなげていければと思っています。