バウンディングボックスの描画とcolormapによる色自動選択

バウンディングボックスを描画する度に、同じようなコードを何度も書いているので、 いい加減コピペで済むようにしたいと思ったので、ここにまとめておく。 今回はアノテーションデータを描画しているが、検出結果でもコードはほぼ同じ。 物体の色はcolormapによって自動選択できるようにしている。 なお、ここで説明するコードは ここ に置いてある。

f:id:Shoto:20200125120158j:plain

概要

描画コードは以下の通り。 引数は順に、画像パス、アノテーションデータ、colormap(後述)、描画画像の保存パス。 cv2.rectangle()バウンディングボックスを、cv2.putText()でラベルを描画している。

def visual_anno(img_path, annos, colormap, drawn_anno_img_path=None):
    # Draw annotion data on image.
    img = cv2.imread(img_path)
    for a in annos:
        color = colormap[a['label_name']]
        cv2.rectangle(img, (a['xmin'], a['ymin']), (a['xmax'], a['ymax']), color, 2)
        text = '{}'.format(a['label_name'])
        cv2.putText(img, text, (a['xmin'], a['ymin']-5), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2, cv2.LINE_AA)

    # Save or show an image.
    if drawn_anno_img_path != None:
        cv2.imwrite(drawn_anno_img_path, img)
    else:
        cv2.imshow('image', img)
        cv2.waitKey(0)
        cv2.destroyAllWindows()

引数

visual_anno()4つの引数は以下の通り。

引数 説明
img_path 画像パス。例: ../data/images/2007_006490.jpg
annos アノテーションデータ。ラベル名とバウンディングボックスの座標を含む辞書のリスト。
colormap ラベル名とRBGの辞書。後述するラベル名とcolormap名を入力すると生成できる。
drawn_anno_img_path 描画画像パス。例: ../experiments/results/drawn_anno_images/2007_006490.jpg

annoscolormapを以下に示す。

annos = [
    {'label_name': 'bird', 'xmin': 80, 'ymin': 309, 'xmax': 109, 'ymax': 332}
    {'label_name': 'person', 'xmin': 143, 'ymin': 165, 'xmax': 169, 'ymax': 248}
    {'label_name': 'boat', 'xmin': 210, 'ymin': 9, 'xmax': 269, 'ymax': 67}
    {'label_name': 'boat', 'xmin': 172, 'ymin': 27, 'xmax': 226, 'ymax': 65}
    {'label_name': 'boat', 'xmin': 128, 'ymin': 25, 'xmax': 185, 'ymax': 67}
]
colormap = {'bird': (255, 0, 40), 'boat': (91, 255, 0), 'person': (0, 143, 255)}

OpenCV関数

cv2.rectangle()cv2.putText()がある。 公式ドキュメント を参考に以下にまとめておく。

cv2.rectangle(img, topLeft, downRight, color, thickness)
引数 説明
img 画像
topLeft 矩形の左上の座標
downRight 矩形の右下の座標
color 矩形の色、RGB
thichness 矩形の線の太さ
cv2.putText(img, text, downLeft, fontFace, fontScale, color, thickness, lineType)
引数 説明
img 画像
text 描画するテキスト
downLeft 文字列の左下の座標
fontFace フォントの種類。次の中から選ぶ。FONT_HERSHEY_SIMPLEX , FONT_HERSHEY_PLAIN , FONT_HERSHEY_DUPLEX , FONT_HERSHEY_COMPLEX , FONT_HERSHEY_TRIPLEX , FONT_HERSHEY_COMPLEX_SMALL , FONT_HERSHEY_SCRIPT_SIMPLEX , FONT_HERSHEY_SCRIPT_COMPLEX
fontScale フォントスケール(画像サイズによってフォントサイズは変化する)
color フォントの色
thickness フォントの線の太さ
lineType 線の種類、基本cv2.LINE_AA(アンチエイリアス)でOK

colormap

物体の色分けは自動で適切に割り当ててくれるとありがたい。 colormap を利用すると簡単に実現できる。

colormapの取得コードは次の通り。 ラベル名のリストとカラーマップ名を入れる。

def get_colormap(label_names, colormap_name):
    colormap = {}   
    cmap = plt.get_cmap(colormap_name)
    for i in range(len(label_names)):
        rgb = [int(d) for d in np.array(cmap(float(i)/len(label_names)))*255][:3]
        colormap[label_names[i]] = tuple(rgb)

    return colormap

ラベル名は、['bird', 'boat', 'person']のようなリスト。 カラーマップ名は、 ここ から文字列を選択する。 例えば、gist_rainbowは以下のようになる。

f:id:Shoto:20200125120215p:plain

plt.get_cmap(colormap_name)でカラーマップcmapが取得できる。 cmapに0~1の値を入れると特定の色が取得できる。 gist_rainbowの場合、0.0が上図の一番左、1.0が一番右を示している。 0.0で赤に近い色、0.4で緑、0.8で青に近い色が取得できる。 1.0以上は同じ色が取得される。 以下に0.1ずつ増やした場合の色の変化を示す。

for i in range(15):
    v = i*0.1
    if i == 11:
        print('=================')
    print(round(v, 1), tuple([int(d) for d in np.array(cmap(v))*255][:3]))
0.0 (255, 0, 40)  # 赤に近い色
0.1 (255, 93, 0)
0.2 (255, 234, 0)
0.3 (140, 255, 0)
0.4 (0, 255, 0)  # 緑
0.5 (0, 255, 139)
0.6 (0, 235, 255)
0.7 (0, 94, 255)
0.8 (41, 0, 255)  # 青に近い色
0.9 (182, 0, 255)
1.0 (255, 0, 191)
=================
1.1 (255, 0, 191)
1.2 (255, 0, 191)
1.3 (255, 0, 191)
1.4 (255, 0, 191)

get_colormap()の中で rgb = [int(d) for d in np.array(cmap(float(i)/len(label_names)))*255][:3] という長い1行があるが、次のような処理を行っている。 簡単に言うと、RGBを適切な値に変換している。

>>> i = 0
>>> label_names = ['bird', 'boat', 'person']
>>> print( float(i)/len(label_names) )
0.0
>>> print( cmap(float(i)/len(label_names)) )
(1.0, 0.0, 0.16, 1.0)
>>> print( np.array(cmap(float(i)/len(label_names))) )
[1.   0.   0.16 1.  ]
>>> print( np.array(cmap(float(i)/len(label_names)))*255 )
[255.    0.   40.8 255. ]
>>> rgb = [int(d) for d in np.array(cmap(float(i)/len(label_names)))*255][:3]
>>> print(rgb)
[255, 0, 40]
>>> print(label_names[i], tuple(rgb))
bird (255, 0, 40)

まとめ

普段はcolormapを使ってなかったのだが、まとめるにあたり使ってみた。 色々試したが、発色やバランス的にgist_rainbowが良かったので採用している。 また自分自身も参照しやすいようにまとまったと思う。

参考文献

ResNet18+ArcFaceでCIFAR10を距離学習

以前、「簡易モデルでMNISTを距離学習」と 「ResNet18でCIFAR10を画像分類」 を実施した。 今回はこれらを組み合わせて「ResNet18+ArcFaceでCIFAR10を距離学習」を行った。

基本的には「ResNet18でCIFAR10を画像分類」 で実施した内容と同じになる。 異なるのはResNet18の最終層の前で特徴抽出して、それをメトリックであるArcFaceに通してから、損失関数に入力している点である。 なので、コード全体の説明は「ResNet18でCIFAR10を画像分類」 に譲るとして、ここではメトリックの周辺の実装について説明する。 なお、今回利用するメトリックはArcFaceで、上記で述べたように画像分類モデルに付け足すだけの優れものである。 しかも非常に精度が高い。 なぜ精度が高くなるのかは 「モダンな深層距離学習 (deep metric learning) 手法: SphereFace, CosFace, ArcFace - Qiita」 が詳しいので一読することをお薦めする。

なお、今回説明するコードは ここ に置いてある。

概要

実行手順は次の通り。

  1. データの取得
  2. モデルの定義
  3. メトリックの定義
  4. 損失関数と最適化関数の定義
  5. 学習と検証

「3. メトリックの定義」が今回新たに実装された部分である。 上記の手順はmain()で次のように実行される。

def main():
    # Parse arguments.
    args = parse_args()
    
    # Set device.
    device = 'cuda' if torch.cuda.is_available() else 'cpu'

    # Load dataset.
    train_loader, test_loader, class_names = cifar10.load_data(args.data_dir)
    
    # Set a model.
    model = get_model(args.model_name, args.n_feats)
    model = model.to(device)
    print(model)

    # Set a metric
    metric = metrics.ArcMarginProduct(args.n_feats, len(class_names), s=args.norm, m=args.margin, easy_margin=args.easy_margin)
    metric.to(device)

    # Set loss function and optimization function.
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD([{'params': model.parameters()}, {'params': metric.parameters()}],
                          lr=args.lr, 
                          weight_decay=args.weight_decay)

    # Train and test.
    for epoch in range(args.n_epoch):
        # Train and test a model.
        train_acc, train_loss = train(device, train_loader, model, metric, criterion, optimizer)
        test_acc, test_loss = test(device, test_loader, model, metric, criterion)
        
        # Output score.
        stdout_temp = 'epoch: {:>3}, train acc: {:<8}, train loss: {:<8}, test acc: {:<8}, test loss: {:<8}'
        print(stdout_temp.format(epoch+1, train_acc, train_loss, test_acc, test_loss))

        # Save a model checkpoint.
        model_ckpt_path = args.model_ckpt_path_temp.format(args.dataset_name, args.model_name, epoch+1)
        torch.save(model.state_dict(), model_ckpt_path)
        print('Saved a model checkpoint at {}'.format(model_ckpt_path))
        print('')

1. データの取得

torchvisionからCIFAR10を取得。 詳しくは こちら を参照。

2. モデルの定義

画像分類モデルはget_model()内で呼び出している。 この時、モデル名と出力する特徴数を引数として渡す。 今回、後者は512にしている。

model = get_model(args.model_name, args.n_feats)

今回はResNet18を使う。 出力クラス数が定義できるので、そこに特徴数を入れる。

from models.resnet import ResNet18

def get_model(model_name, num_classes=512):
    ...
    elif model_name == 'ResNet18':
        model = ResNet18(num_classes)

modelディレクトリーのresnet.pyを呼び出す。 以下が中身。 最終層で512次元の特徴を返す。

def ResNet18(n_feats):
    return ResNetFace(BasicBlock, [2,2,2,2], num_classes=n_feats)


class ResNetFace(nn.Module):
    def __init__(self, block, num_blocks, num_classes=10):
        super(ResNetFace, self).__init__()
        self.in_planes = 64

        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
        self.linear = nn.Linear(512*block.expansion, num_classes)

    def _make_layer(self, block, planes, num_blocks, stride):
        strides = [stride] + [1]*(num_blocks-1)
        layers = []
        for stride in strides:
            layers.append(block(self.in_planes, planes, stride))
            self.in_planes = planes * block.expansion
        return nn.Sequential(*layers)

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)
        out = F.avg_pool2d(out, 4)
        out = out.view(out.size(0), -1)
        out = self.linear(out)
        return out

3. メトリックの定義

同じクラスを近くに、異なるクラスを遠くに置くようにするための損失関数を指す。 ArcFaceは、簡単にいうと下図のように円弧上(実際には超球面上)にクラスが適切に分布するように角度を学習する損失関数となる。

f:id:Shoto:20200118113944p:plain

コードは これ がちゃんと動いた。 ArcMarginProduct()がArcFace。

metric = metrics.ArcMarginProduct(args.n_feats, len(class_names), s=args.norm, m=args.margin, easy_margin=args.easy_margin)

第1引数のargs.n_featsは入力する特徴数。 これはモデルの出力数でもある。 第2引数のlen(class_names)はクラス数。 sは下図のLogit前のFeature Re-scaleに当たる。 Logitの値がsoftmaxで機能するよう適切な値にスケールする。 mはAdditive Anguler Mergin Penaltyにあたる。 つまりマージンによるペナルティーで、 クラスの重みW_{y_i}と画像の特徴ベクトルx_iのなす角度\theta _{y_i}を最小化するのだが、 その際マージンmをペナルティーとして加えることで、同じクラスを近くに、異なるクラスを遠くに置くようにするための効果が増す。

f:id:Shoto:20200118114003p:plain

easy_marginは以下のような処理を行っているが、理解しきれなかったので暇ができたら後で調べる。 ちなみにeasy_marginTrueにしないと全然学習しなかったので注意。

if self.easy_margin:
        phi = torch.where(cosine > 0, phi, cosine)
    else:
        phi = torch.where(cosine > self.th, phi, cosine - self.mm)

図ではSoftmaxにかけたあと、CrossEntropyLossを算出している。 PyTorchではcriterionに指定したCrossEntropyLossSoftmaxも内包されているため、 特に記述する必要がない。

criterion = nn.CrossEntropyLoss()

features = model(inputs)
outputs = metric_fc(features, targets)
loss = criterion(outputs, targets)

4. 損失関数と最適化関数の定義

損失関数は上記で述べた通り。 最適化関数はSGDを使い、paramsにモデルとメトリックの両方を渡す。

optimizer = optim.SGD([{'params': model.parameters()}, {'params': metric.parameters()}],
                      lr=args.lr, 
                      weight_decay=args.weight_decay)

5. 学習と検証

画像分類と同様に訓練を実施した結果、 100エポックでテスト精度が82.4%になった。

epoch: 100, train acc: 0.947966, train loss: 0.003992, test acc: 0.824237, test loss: 0.027159

ちなみにResNet18の最終層をコメントアウトしても512次元の特徴が取れるのだが、 このケースではテスト精度は84.7%になった。 CIFAR10ではResNet18でもDeepなのかも。

def forward(self, x):
    out = F.relu(self.bn1(self.conv1(x)))
    out = self.layer1(out)
    out = self.layer2(out)
    out = self.layer3(out)
    out = self.layer4(out)
    out = F.avg_pool2d(out, 4)
    out = out.view(out.size(0), -1)
    #out = self.linear(out)
    return out
epoch: 100, train acc: 0.955092, train loss: 0.004396, test acc: 0.847445, test loss: 0.028565

所感

中身が完全に理解できていないのと、モデルとメトリックのパラメーター調整がもっと必要な気がしている。 時間があったら、もっと突っ込んでやりたい。

参考文献

ResNet18でCIAFR10を画像分類

CIFAR10の画像分類は PyTorchのチュートリアル に従ったらできるようになったのだが、 オリジナルモデルだったためResNet18に変更しようとしたら少しつまづいた。 再度つまづかないために、ここに実行手順をコード解説付きでまとめておく。 なお全コードは ここ に置いてある。

概要

実行手順は次の通り。

  1. データ取得
  2. モデル定義
  3. 損失関数と最適化関数の定義
  4. 学習と検証

これらはmain()で次のように実行される。

def main():
    # Parse arguments.
    args = parse_args()
    
    # Set device.
    device = 'cuda' if torch.cuda.is_available() else 'cpu'

    # Load dataset.
    train_loader, test_loader, class_names = cifar10.load_data(args.data_dir)
    
    # Set a model.
    model = get_model(args.model_name)
    model = model.to(device)

    # Set loss function and optimization function.
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(model.parameters(), lr=args.lr, momentum=0.9, weight_decay=5e-4)

    # Train and test.
    for epoch in range(args.n_epoch):
        # Train and test a model.
        train_acc, train_loss = train(model, device, train_loader, criterion, optimizer)
        test_acc, test_loss = test(model, device, test_loader, criterion)
        
        # Output score.
        stdout_temp = 'epoch: {:>3}, train acc: {:<8}, train loss: {:<8}, test acc: {:<8}, test loss: {:<8}'
        print(stdout_temp.format(epoch+1, train_acc, train_loss, test_acc, test_loss))

        # Save a model checkpoint.
        model_ckpt_path = args.model_ckpt_path_temp.format(args.dataset_name, args.model_name, epoch+1)
        torch.save(model.state_dict(), model_ckpt_path)
        print('Saved a model checkpoint at {}'.format(model_ckpt_path))
        print('')

1. データ取得

CIFAR10を利用する。 今後の拡張性も考えて、データセット読み込み用のdatasetsディレクトリーを作って、 CIFAR10関連のコードはcifar10.pyにまとめている。

from datasets import cifar10

def main():
    ...

    # Load dataset.
    train_loader, test_loader, class_names = cifar10.load_data(args.data_dir)

cifar10.pyの中身は次の通り。 CIFAR10はtorchvisionがあればOKなので実装は簡単。 trainとtestのDataLoaderとクラス名を返す。

import torchvision
import torchvision.transforms as transforms

def load_data(data_dir):
    transform_train = transforms.Compose([
        transforms.RandomCrop(32, padding=4),
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
    ])

    transform_test = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
    ])

    train_set = torchvision.datasets.CIFAR10(root=data_dir, train=True, download=True, transform=transform_train)
    train_loader = torch.utils.data.DataLoader(train_set, batch_size=128, shuffle=True, num_workers=0)
    test_set = torchvision.datasets.CIFAR10(root=data_dir, train=False, download=True, transform=transform_test)
    test_loader = torch.utils.data.DataLoader(test_set, batch_size=100, shuffle=False, num_workers=0)
    class_names = ('plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck')

    return train_loader, test_loader, class_names

2. モデル定義

Train CIFAR10 with PyTorchmodelsに ResNet以外にも様々なコードがあったのでディレクトリーごと拝借した。

from models import *

def main():
    ...

    # Set a model.
    model = get_model(args.model_name)

色々あるので引数にモデル名を入れれば取得できるようにした。

def get_model(model_name):
    if model_name == 'VGG19':
        model = VGG('VGG19')
    elif model_name == 'ResNet18':
        model = ResNet18()
    elif model_name == 'PreActResNet18':
        model = PreActResNet18()
    ...
    elif model_name == 'SENet18':
        model = SENet18()
    elif model_name == 'ShuffleNetV2':
        model = ShuffleNetV2(1)
    elif model_name == 'EfficientNetB0':
        model = EfficientNetB0()
    else:
        print('{} does NOT exist in repertory.'.format(model_name))
        sys.exit(1)

3. 損失関数と最適化関数の定義

今回はオーソドックスにCross EntropyとSGDを各々セット。

def main():
    ...
    
    # Set loss function and optimization function.
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(model.parameters(), lr=args.lr, momentum=0.9, weight_decay=5e-4)

4. 学習と検証

これまでセットして変数をtrain()に入力して訓練を開始する。

def main():
    ...

    # Train and test.
    for epoch in range(args.n_epoch):
        # Train and test a model.
        train_acc, train_loss = train(model, device, train_loader, criterion, optimizer)

train()では一般的な処理を踏む。 スコア算出のために、出力結果と正解のリスト、およびロスを貯める。

def train(model, device, train_loader, criterion, optimizer):
    model.train()

    output_list = []
    target_list = []
    running_loss = 0.0
    for batch_idx, (inputs, targets) in enumerate(train_loader):
        # Forward processing.
        inputs, targets = inputs.to(device), targets.to(device)
        outputs = model(inputs)
        loss = criterion(outputs, targets)
        
        # Backward processing.
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        # Set data to calculate score.
        output_list += [int(o.argmax()) for o in outputs]
        target_list += [int(t) for t in targets]
        running_loss += loss.item()

        # Calculate score at present.
        train_acc, train_loss = calc_score(output_list, target_list, running_loss, train_loader)
        if batch_idx % 10 == 0 and batch_idx != 0:
            stdout_temp = 'batch: {:>3}/{:<3}, train acc: {:<8}, train loss: {:<8}'
            print(stdout_temp.format(batch_idx, len(train_loader), train_acc, train_loss))

    # Calculate score.
    train_acc, train_loss = calc_score(output_list, target_list, running_loss, train_loader)

    return train_acc, train_loss

スコア算出はcalc_score()で行う。 精度の算出はscikit-learnのclassification_report()を用いる。

def calc_score(output_list, target_list, running_loss, data_loader):
    # Calculate accuracy.
    result = classification_report(output_list, target_list, output_dict=True)
    acc = round(result['weighted avg']['f1-score'], 6)
    loss = round(running_loss / len(data_loader.dataset), 6)

    return acc, loss

検証用のメソッドtest()も中身は学習する以外はだいたいtrain()と同じ。 両メソッドから算出したスコアを取得して表示する。

def main():
    ...

    # Train and test.
    for epoch in range(args.n_epoch):
        # Train and test a model.
        train_acc, train_loss = train(model, device, train_loader, criterion, optimizer)
        test_acc, test_loss = test(model, device, test_loader, criterion)
        
        # Output score.
        stdout_temp = 'epoch: {:>3}, train acc: {:<8}, train loss: {:<8}, test acc: {:<8}, test loss: {:<8}'
        print(stdout_temp.format(epoch+1, train_acc, train_loss, test_acc, test_loss))

以下が実行中の標準出力。 10バッチごとと1エポックごとに出力する。

$ python train.py
Files already downloaded and verified
Files already downloaded and verified
batch:  10/391, train acc: 0.176831, train loss: 0.000512
batch:  20/391, train acc: 0.208884, train loss: 0.000931
batch:  30/391, train acc: 0.214069, train loss: 0.001356
...
batch: 390/391, train acc: 0.424302, train loss: 0.012254
epoch:   1, train acc: 0.424302, train loss: 0.012254, test acc: 0.539407, test loss: 0.012638
Saved a model checkpoint at ../experiments/models/checkpoints/CIFAR10_ResNet18_epoch=1.pth

ほぼtest()と同じで、学習済みモデルを読み込んで評価を行うtest.pyも作成した。 モデルは10エポックで精度77.9%ほどとなることを確認。

$ python test.py
Files already downloaded and verified
Files already downloaded and verified
Loaded a model from ../experiments/models/CIFAR10_ResNet18_epoch=10.pth
test acc: 0.779172, test loss: 0.006796

参考文献

MNISTを画像検索

前回「MNISTで距離学習」 という記事を書いたが画像分類の域を出なかった。 距離学習と言えば画像検索なので、今回はそれをMNISTで行った。

概要

今回は前回 訓練したMNISTの距離学習モデルを利用して画像検索を行う。 手順は次の通り。

  1. データ準備
  2. モデルロード
  3. 特徴抽出
  4. 距離算出

これらについて順番に説明する。 なお、コードはここに置いてある。

1. データ準備

テスト時、画像分類ではテストデータを用意するが、 画像検索では検索画像となるQueryと検索対象画像群となるGalleryが必要になる。 そこでまずはテストデータをQueryとGalleryに分ける。 また後々のことを考えて、いったん画像に保存したものを読み込むようにする。

ここではメインファイルのimage_retrieval.pymake_query_and_gallery_from_mnist()を呼び出す。 これにより、MNISTから1枚をQueryに100枚をGalleryにランダム選択して振り分ける。 各画像はQueryとGallery各々のディレクトリーに保存され、それらの情報はCSVファイルに記載される。

dataset_dirはMNISTを保存するディレクトリー、 query_dirはQuery画像を保存するディレクトリー、 gallery_dirはGallery画像を保存するディレクトリー、 anno_pathはQueryとGalleryの画像情報を記載したCSVファイル である。

make_query_and_gallery_from_mnist(args.dataset_dir, args.query_dir, args.gallery_dir, args.anno_path)

make_query_and_gallery_from_mnist()では、 まずmake_query_and_gallery()でMNISTの画像をQueryとGalleryに振り分けて、 次にmake_anno_file()でQueryとGalleryの画像情報をCSVに保存する。 これらはMNISTデータ処理専用のmnist_data.pyで行う。

def make_query_and_gallery_from_mnist(dataset_dir, query_dir, gallery_dir, anno_path):
    mnist_data.make_query_and_gallery(dataset_dir, query_dir, gallery_dir)
    mnist_data.make_anno_file(query_dir, gallery_dir, anno_path)

以下からmnist_data.pymake_query_and_gallery()は次の通り。 transformしたMNISTを取得して、Query画像1枚とGallery画像100枚をランダムに選択後、 各々のディレクトリーに画像として保存している。 保存前の画像はtransfromで正規化した後なので0~255になっていないが、 scipy.misc.imsave()を使うと0~255にして保存してくれる。

def make_query_and_gallery(dataset_dir, query_dir, gallery_dir):
    # 
    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.1307,), (0.3081,))
    ])
    testset = datasets.MNIST(dataset_dir, train=False, download=True, transform=transform)
    q_idx = random.choice(range(len(testset)))
    g_idxs= random.sample(range(len(testset)), 100)
    
    # Save query image.
    if os.path.exists(query_dir) == True:
        shutil.rmtree(query_dir)
    os.makedirs(query_dir)
    q_img, q_label = testset[q_idx]
    scipy.misc.imsave(query_dir+'{}_{}.png'.format(q_label, q_idx), q_img.numpy()[0])
    
    # Save gallery images.
    if os.path.exists(gallery_dir) == True:
        shutil.rmtree(gallery_dir)
    os.makedirs(gallery_dir)
    for g_idx in g_idxs:
        g_img, g_label = testset[g_idx]
        scipy.misc.imsave(gallery_dir+'{}_{}.png'.format(g_label, g_idx), g_img.numpy()[0])

make_anno_file()では、Query/Gallery、 画像名、画像パス、ラベル名、IDを記載したCSVファイルを作成して保存する。

def make_anno_file(query_dir, gallery_dir, anno_path):
    annos = []
    annos += __set_annos(query_dir, 'query')
    annos += __set_annos(gallery_dir, 'gallery')
    df = pd.DataFrame(annos)
    df.to_csv(anno_path, index=False)


def __set_annos(img_dir, data_type):
    annos = []
    for d in os.listdir(img_dir):
        dic = {}
        dic['data_type'] = data_type
        dic['img_name'] = d
        dic['img_path'] = img_dir + d
        dic['label'] = d.split('_')[0]
        dic['id'] = d.split('.')[0].split('_')[1]
        annos.append(dic)

    return annos

最後に画像検索用のDataLoaderを作成する。 先ほど作成したQueryとGalleryのCSVファイルを用いて、 各々の画像ローダーが作成できる仕様にしている。 また、画像とラベルの他に画像パスも返すようにしている。

class ReIDDataset(Dataset):
    def __init__(self, anno_path, data_type, transform=None):
        df_all = pd.read_csv(anno_path)
        self.df = df_all[df_all['data_type']==data_type].reset_index(drop=True)  # Filter data by query or gallery.
        self.transform = transform


    def __len__(self):
        return len(self.df)


    def __getitem__(self, idx):
        img_path = self.df.loc[idx, 'img_path']
        assert os.path.exists(img_path)
        image = io.imread(img_path)
        label = self.df.loc[idx, 'label']
        img_path = self.df.loc[idx, 'img_path']
        if self.transform:
            image = self.transform(image)
        
        return image, label, img_path

画像分類の時にtrain_loader、test_loader、classesを返していたのと同様、 画像検索の時はquery_loader、gallery_loader、classesを返す。

def load_query_and_gallery(anno_path, img_show=False):
    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.1307,), (0.3081,))
    ])

    # Query
    query_dataset = ReIDDataset(anno_path, 'query', transform)
    query_loader = DataLoader(query_dataset, batch_size=len(query_dataset), shuffle=False)
        
    # Gallery
    gallery_dataset = ReIDDataset(anno_path, 'gallery', transform)
    #gallery_loader = DataLoader(gallery_dataset, batch_size=len(gallery_dataset), shuffle=True)
    gallery_loader = DataLoader(gallery_dataset, batch_size=8, shuffle=True)
    
    # Class
    classes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

    # debug
    print('num query: {}, num gallery: {}'.format(len(query_dataset), len(gallery_dataset)))
    print('')
    if img_show == True:
        show_data(gallery_loader)

    return query_loader, gallery_loader, classes

2. モデルロード

再びメインファイルのimage_retrieval.py。 モデルのロードは、model.load_state_dict(torch.load(args.model_path))で行う。 学習済みモデルは、前回の記事を参考に生成する。 今回はテストなのでeval()で評価モードにしておく。

# Set device, GPU or CPU.
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# Model
model = Net().to(device)
model.load_state_dict(torch.load(args.model_path))
model.eval()

3. 特徴抽出

1. データ準備で説明したquery_loaderを利用する。 と言っても画像は1枚だけなので、学習済みモデルに通して特徴を取得するだけ。 modelの出力は2次元特徴と予測結果なるが、今回は前者のみを利用する。

# Query
for i, (query_img, query_label, query_path) in enumerate(query_loader):
    with torch.no_grad():
        query_img = query_img.to(device)
        query_feat, pred = model(query_img)

Galleryは100枚画像があるが、 gallery_loaderのバッチサイズは8にしているので、 各情報をリストに格納して、最後にconcatinateする。

# Gallery
gallery_feats = []
gallery_labels = []
gallery_paths = []
for i, (g_imgs, g_labels, g_paths) in enumerate(gallery_loader):
    with torch.no_grad():
        g_imgs = g_imgs.to(device)
        g_feats_temp, preds_temp = model(g_imgs)
        gallery_feats.append(g_feats_temp)
        gallery_labels.append(g_labels)
        gallery_paths += list(g_paths)  # Data type of g_paths is tuple.
gallery_feats = torch.cat(gallery_feats, 0)
gallery_labels = torch.cat(gallery_labels, 0)

4. 距離算出

3. 特徴抽出でQueryの特徴query_featとGalleryの特徴gallery_featsが取得できた。 query_featgallery_featsの各特徴との距離を算出するためにコサイン類似度を利用する。

dist_matrix = cosine_similarity(query_feat, gallery_feats)

コサイン類似の実装は次の通り。

def cosine_similarity(qf, gf):
    epsilon = 0.00001
    dist_mat = qf.mm(gf.t())
    qf_norm = torch.norm(qf, p=2, dim=1, keepdim=True) #mx1
    gf_norm = torch.norm(gf, p=2, dim=1, keepdim=True) #nx1
    qg_normdot = qf_norm.mm(gf_norm.t())

    dist_mat = dist_mat.mul(1/qg_normdot).cpu().numpy()
    dist_mat = np.clip(dist_mat, -1+epsilon,1-epsilon)
    dist_mat = np.arccos(dist_mat)

    return dist_mat

QueryとGalleryの各距離、Galleryのラベル名と画像パスをセットにした DataFrameを作成して、距離でソートする。 これにより、距離の近い順にラベルが表示される。

# Organize ReID ranking.
lis = []
for i in range(len(gallery_paths)):
    dic = {}
    dic['dist'] = dist_matrix.tolist()[0][i]
    dic['label'] = np.array(gallery_labels).tolist()[i]
    dic['img_path'] = gallery_paths[i]
    lis.append(dic)
df = pd.DataFrame(lis)
df = df.sort_values(by=['dist'], ascending=True)
df = df.reset_index(drop=True)

以下は実行結果。 Queryが9で、Galleryも9がindexの0から8まで占めている。

$ python image_retrieval.py
num query: 1, num gallery: 100

Query Image Label: 9

Search Result
        dist                      img_path  label
0   0.005382  ../inputs/gallery/9_1801.png      9
1   0.018921  ../inputs/gallery/9_4237.png      9
2   0.036690   ../inputs/gallery/9_481.png      9
3   0.047976  ../inputs/gallery/9_7380.png      9
4   0.069177  ../inputs/gallery/9_8213.png      9
5   0.076138  ../inputs/gallery/9_3970.png      9
6   0.078646  ../inputs/gallery/9_2685.png      9
7   0.107746  ../inputs/gallery/9_5977.png      9
8   0.387746  ../inputs/gallery/9_4505.png      9
9   0.523175  ../inputs/gallery/3_8981.png      3
10  0.538863   ../inputs/gallery/3_927.png      3
11  0.560314   ../inputs/gallery/3_142.png      3
12  0.565455  ../inputs/gallery/3_8451.png      3
13  0.582634  ../inputs/gallery/3_4755.png      3
14  0.586750  ../inputs/gallery/3_2174.png      3
15  0.589938  ../inputs/gallery/3_9986.png      3
16  0.675965  ../inputs/gallery/1_4491.png      1
17  0.682165  ../inputs/gallery/3_8508.png      3
18  0.683414  ../inputs/gallery/3_4785.png      3
19  0.698637  ../inputs/gallery/1_1038.png      1

labelをカウントすると、ラベルが9の画像は9枚あることが分かる。 よって、Galleryにあるラベル9の全画像を検索上位に持ってくることができたことが分かる。

1    15
7    13
0    12
8    11
4    10
3    10
9     9  # <- this
6     7
2     7
5     6

参考文献

MNISTを距離学習

Person ReIDが必要になったので、まずはMNISTを題材に距離学習を勉強している。 あと、これまでKerasを使ってきたけど、PyTorch使えないと厳しい世の中になってきたので、 PyTorchについて色々調べつつ実装してみた。

なお今回はこちらの記事(以下、参照記事)を参考にしている。 距離学習をメインで学びたい人は本記事より参照記事を読むことをお薦めする。 本記事はPyTorch入門みたいな要素が強いので。

概要

距離学習をもの凄く簡単に言うと画像分類の拡張。 なので、処理フローはだいたい画像分類と同じで以下のようになる。

  1. データ準備
  2. モデル定義
  3. 損失関数定義
  4. 最適化関数定義
  5. 訓練検証

距離学習は、同じクラスは近く異なるクラスは遠くなるようにモデルを学習することで、 未知のクラスの同定を行えるのが画像分類と違うところ。 ポイントは損失関数で、今回はCenterLossというのを使っているが、 説明は参照記事が詳しい。

本記事で説明するコードはここにある。 以下のtrain_mnist_original_center.pymain()を実行すると、 参照記事と同じような結果が得られるが、個人的にコード整理してみたので、 上述の処理フローに従って順に説明する。

def main():
    # Arguments
    args = parse_args()

    # Dataset
    train_loader, test_loader, classes = mnist_loader.load_dataset(args.dataset_dir, img_show=True)

    # Device
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    # Model
    model = Net().to(device)
    print(model)

    # Loss
    nllloss = nn.NLLLoss().to(device)  # CrossEntropyLoss = log_softmax + NLLLoss
    loss_weight = 1
    centerloss = CenterLoss(10, 2).to(device)
    
    # Optimizer
    dnn_optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9, weight_decay=0.0005)
    sheduler = lr_scheduler.StepLR(dnn_optimizer, 20, gamma=0.8)
    center_optimizer = optim.SGD(centerloss.parameters(), lr =0.5)
    
    print('Start training...')
    for epoch in range(100):
        # Update parameters.
        epoch += 1
        sheduler.step()

        # Train and test a model.
        train_acc, train_loss, feat, labels = train(device, train_loader, model, nllloss, loss_weight, centerloss, dnn_optimizer, center_optimizer)
        test_acc, test_loss = test(device, test_loader, model, nllloss, loss_weight, centerloss)
        stdout_temp = 'Epoch: {:>3}, train acc: {:<8}, train loss: {:<8}, test acc: {:<8}, test loss: {:<8}'
        print(stdout_temp.format(epoch, train_acc, train_loss, test_acc, test_loss))
        
        # Visualize features of each class.
        vis_img_path = args.vis_img_path_temp.format(str(epoch).zfill(3))
        visualize(feat.data.cpu().numpy(), labels.data.cpu().numpy(), epoch, vis_img_path)

        # Save a trained model.
        model_path = args.model_path_temp.format(str(epoch).zfill(3))
        torch.save(model.state_dict(), model_path)

1. データ準備

先に引数の説明を少し。

# Arguments
args = parse_args()

dataset_dirはMNISTデータの保存場所。 後述するPyTorchの機能でここにダウンロードしてくれる。 model_path_tempは学習済みモデルのチェックポイント。 各エポック終了後に保存する。 vis_img_path_tempはMNISTの各クラスの特徴分布を可視化した画像。 こちらも各エポック終了後に保存する。 だんだんとクラス内でまとまりクラス間が離れていく様子が確認できる。 下図は100エポック後の特徴分布。

def parse_args():
    arg_parser = argparse.ArgumentParser(description="parser for focus one")

    arg_parser.add_argument("--dataset_dir", type=str, default='../inputs/')
    arg_parser.add_argument("--model_path_temp", type=str, default='../outputs/models/checkpoints/mnist_original_softmax_center_epoch_{}.pth')
    arg_parser.add_argument("--vis_img_path_temp", type=str, default='../outputs/visual/epoch_{}.png')
    
    args = arg_parser.parse_args()

    return args

f:id:Shoto:20200104213047p:plain

では、MNISTのデータセットを取得する。 MNIST関連は、mnist_loader.pyという別ファイルを作って処理している。

# Dataset
train_loader, test_loader, classes = mnist_loader.load_dataset(args.dataset_dir, img_show=True)

load_datasetは、train_loader、test_loader、クラス名を取得するメソッド。 ここからPyTorch色が強くなるが、データ準備では次の手順を踏む。

1. 画像の前処理

torchvisiontransformを利用する。 ToTensor()でPyTorchのtorch.Tensor型に変換する。 他にも、クロップやフリップなどData Augmentation的な事を行えるが、今回は未実施。 今回はNormalize()で正規化を行っている。 なおMNISTは自然画像ではないので、平均0.1307、標準偏差0.3081となるようにする。

from torchvision import transforms
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])
2. 画像データセットを取得

torchvisiondatasets.MNISTを使うとMNISTが簡単に利用できる。 第1引数はMNISTデータの保存場所。 第2引数でtrain用かtest用かを選ぶ。 第3引数がTrueの場合は保存場所にMNISTデータがない場合に自動でダウンロードしてくれる。 第4引数で先に定義したtransformをセットする。

from torchvision import datasets
trainset = datasets.MNIST(dataset_dir, train=True, download=True, transform=transform)
3. データローダーを定義

torch.utils.dataDataLoaderを利用して、指定バッチ数分のデータを取得する。 第1引数は2で定義したデータセット。 第2引数はバッチサイズ。 第3引数はデータシャッフルするか否か。訓練時はTrueが妥当。 第4引数はデータロードの並列処理数。

from torch.utils.data import DataLoader
train_loader = DataLoader(trainset, batch_size=train_batch_size, shuffle=True, num_workers=0)

上記のメソッドを組み合わせることで、mnist_loader.load_dataset()は次のようになる。

def load_dataset(dataset_dir, train_batch_size=128, test_batch_size=128, img_show=False):
    # Dataset
    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.1307,), (0.3081,))
    ])
    trainset = datasets.MNIST(dataset_dir, train=True, download=True, transform=transform)
    train_loader = DataLoader(trainset, batch_size=train_batch_size, shuffle=True, num_workers=0)
    testset = datasets.MNIST(dataset_dir, train=False, download=True, transform=transform)
    test_loader = DataLoader(testset, batch_size=test_batch_size, shuffle=False, num_workers=0)
    classes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

    if img_show == True:
        show_data(train_loader)

    return train_loader, test_loader, classes

show_data()はMNISTを可視化するメソッド。 torchvision.utils.make_grid()により train_loader のバッチを簡単に可視化できる。

def show_data(data_loader):
    images, labels = iter(data_loader).next()  # data_loader のミニバッチの image を取得
    img = torchvision.utils.make_grid(images, nrow=16, padding=1)  # nrom*nrom のタイル形状の画像を作る
    plt.ion()
    plt.imshow(np.transpose(img.numpy(), (1, 2, 0)))  # 画像を matplotlib 用に変換
    plt.draw()
    plt.pause(3)  # Display an image for three seconds.
    plt.close()

2. モデル定義

PyTorchでは処理をGPUとCPUのどちらで行うかtorch.deviceで明示的に選択して、 それをモデルやデータにセットする必要がある。 モデル定義はMNIST向けのをmnist_net.pyNet()で別途定義している。

# Device
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# Model
model = Net().to(device)
print(model)

mnist_net.pyNet()は次の通り。 Define by Runでは、 __init__()で計算グラフを幾つか定義して、ネットワーク生成時に1度だけ呼びし、 データ入力時にforward()を呼び出す使用となっている。 6つの畳み込み層とPReLUの後、2次元空間に落とし込んだ特徴ip1と、 それをPReLUに通して10次元空間に写像したip2を出力する。 ip1が特徴分布で、ip2は画像分類に利用する。

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1_1 = nn.Conv2d(1, 32, kernel_size=5, padding=2)
        self.prelu1_1 = nn.PReLU()
        self.conv1_2 = nn.Conv2d(32, 32, kernel_size=5, padding=2)
        self.prelu1_2 = nn.PReLU()
        self.conv2_1 = nn.Conv2d(32, 64, kernel_size=5, padding=2)
        self.prelu2_1 = nn.PReLU()
        self.conv2_2 = nn.Conv2d(64, 64, kernel_size=5, padding=2)
        self.prelu2_2 = nn.PReLU()
        self.conv3_1 = nn.Conv2d(64, 128, kernel_size=5, padding=2)
        self.prelu3_1 = nn.PReLU()
        self.conv3_2 = nn.Conv2d(128, 128, kernel_size=5, padding=2)
        self.prelu3_2 = nn.PReLU()
        self.ip1 = nn.Linear(128*3*3, 2)
        self.preluip1 = nn.PReLU()
        self.ip2 = nn.Linear(2, 10, bias=False)
 
    def forward(self, x):
        x = self.prelu1_1(self.conv1_1(x))
        x = self.prelu1_2(self.conv1_2(x))
        x = F.max_pool2d(x,2)
        x = self.prelu2_1(self.conv2_1(x))
        x = self.prelu2_2(self.conv2_2(x))
        x = F.max_pool2d(x,2)
        x = self.prelu3_1(self.conv3_1(x))
        x = self.prelu3_2(self.conv3_2(x))
        x = F.max_pool2d(x,2)
        x = x.view(-1, 128*3*3)
        ip1 = self.preluip1(self.ip1(x))
        ip2 = self.ip2(ip1)
        return ip1, F.log_softmax(ip2, dim=1)

なおnn.PReLUはReLUの改良の改良。 LeakyReLU で x < 0 の時に y < 0 することで学習が進みやすくなったものの、 パラメーター α が増えたため、それを減らすべく学習させることにしたのがPReLU。 ちなみにPReLUは、"a Parametric Rectified Linear Unit" の略。 まとめると以下のようになる。

ReLU
    y = x (0 =< x)
    y = 0 (x < 0)

LeakyReLU
    y = x (0 =< x)
    y = αx (x < 0), set α as a parameter

PReLU
    y = x (0 =< x)
    y = αx (x < 0), learning α

またviewnumyp.reshapeと同じ。 第一引数が-1のとき、第二引数の形に自動調整してくれる。 上記の場合だと、x の shape が (3, 3, 128) になるので、 1次元に変換している。

3. 損失関数定義

損失関数は、画像分類用のNLL LossにMetric Learninig用のCenter Lossを加重加算したものを利用する。

Loss = NLL Loss + α * Center Loss, α is weight

NLL LossはNegative Log-Likelihood (NLL) Lossの略。 softmaxの最大値は結果の確信度を表すが、それをマイナスの対数で取った値となる。 NLL Lossにより、高い確信度であれば低いロス、低い確信度であれば高いロスを割り当てることができる。 ip2のsoftmax(定義したモデルの出力)を入力とする。

一方Center Lossは特徴の中心の損失関数。ip1を入力する。 詳しい説明は参照記事に任せる。 ちなみに、自分は距離学習に ArcFaceから入ったので、 Center Lossはこの記事以外では使わないかな、と思っている。

PyTorchで、損失関数は次のように定義される。 CenterLoss()は自作関数でクラス数と特徴数が引数となる。

# NLL Loss & Center Loss
nllloss = nn.NLLLoss().to(device)  # CrossEntropyLoss = log_softmax + NLLLoss
loss_weight = 1  # weight
centerloss = CenterLoss(10, 2).to(device)
# Loss
loss = nllloss(pred, labels) + loss_weight * centerloss(labels, ip1)

4. 最適化関数定義

最適化関数にはSGDを利用するが、画像分類と距離特徴の両方を行っているので、 それぞれで最適化関数を定義する。 前者については、学習率の減衰をlr_scheduler.StepLR()で行う。 第一引数は画像分類用の最適化関数、第二引数は学習率を更新するタイミングのエポック数、 第三引数は学習率の更新率。

# Optimizer
dnn_optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9, weight_decay=0.0005)
center_optimizer = optim.SGD(centerloss.parameters(), lr =0.5)
import torch.optim.lr_scheduler as lr_scheduler
sheduler = lr_scheduler.StepLR(dnn_optimizer, 20, gamma=0.8)

5. 訓練検証

これまで定義してきた変数と関数を利用して訓練を行う。 エポックごとにtrain()を呼び出す。

train_acc, train_loss, feat, labels = train(device, train_loader, model, nllloss, loss_weight, centerloss, dnn_optimizer, center_optimizer)

train()は一般的な機械学習

def train(device, train_loader, model, nllloss, loss_weight, centerloss, dnn_optimizer, center_optimizer):
    running_loss = 0.0
    pred_list = []
    label_list = []
    ip1_loader = []
    idx_loader = []
    
    model.train()
    for i,(imgs, labels) in enumerate(train_loader):
        # Set batch data.
        imgs, labels = imgs.to(device), labels.to(device)
        # Predict labels.
        ip1, pred = model(imgs)
        # Calculate loss.
        loss = nllloss(pred, labels) + loss_weight * centerloss(labels, ip1)
        # Initilize gradient.
        dnn_optimizer.zero_grad()
        center_optimizer.zero_grad()
        # Calculate gradient.
        loss.backward()
        # Update parameters.
        dnn_optimizer.step()
        center_optimizer.step()
        # For calculation.
        running_loss += loss.item()
        pred_list += [int(p.argmax()) for p in pred]
        label_list += [int(l) for l in labels]
        # For visualization.
        ip1_loader.append(ip1)
        idx_loader.append((labels))
    
    # Calculate training accurary and loss.
    result = classification_report(pred_list, label_list, output_dict=True)
    train_acc = round(result['weighted avg']['f1-score'], 6)
    train_loss = round(running_loss / len(train_loader.dataset), 6)
    
    # Concatinate features and labels.
    feat = torch.cat(ip1_loader, 0)
    labels = torch.cat(idx_loader, 0)
    
    return train_acc, train_loss, feat, labels

PyTorchでは訓練中、lossとoptimizerはバッチごとに次の手順を踏んで、パラメーターを更新していく。

optimizer.zero_grad()  # 勾配の初期化
loss.backward()  # 勾配の計算
optimizer.step()  # パラメータの更新

またsklearn.metricsclassification_report()を利用すると、 簡単に精度が算出できる。 今回のように距離学習の損失関数を入れても、検証精度は100エポックで98.2%になっている。 訓練と検証の精度と損失の変化は以下の通り。

Epoch:   1, train acc: 0.209305, train loss: 0.019642, test acc: 0.253963, test loss: 0.018308
Epoch:   2, train acc: 0.302789, train loss: 0.017461, test acc: 0.418725, test loss: 0.016906
Epoch:   3, train acc: 0.455266, train loss: 0.015967, test acc: 0.492158, test loss: 0.015241
Epoch:   4, train acc: 0.531249, train loss: 0.014266, test acc: 0.526375, test loss: 0.013594
Epoch:   5, train acc: 0.609915, train loss: 0.012737, test acc: 0.629488, test loss: 0.012123
...
Epoch:  96, train acc: 1.0     , train loss: 0.000309, test acc: 0.982005, test loss: 0.003887
Epoch:  97, train acc: 0.999983, train loss: 0.000307, test acc: 0.981601, test loss: 0.003929
Epoch:  98, train acc: 1.0     , train loss: 0.000303, test acc: 0.981306, test loss: 0.003924
Epoch:  99, train acc: 1.0     , train loss: 0.000296, test acc: 0.981907, test loss: 0.003937
Epoch: 100, train acc: 1.0     , train loss: 0.000272, test acc: 0.981805, test loss: 0.003965

参考文献

【論文解説】SinGAN: Learning a Generative Model from a Single Natural Image

1枚の画像に対して様々な操作が可能な SinGAN の論文を簡単にまとめた。 SinGANはSingle GANの略。 個人的には、ICCV 2019でbest paperだったのと、GANだけど必要な訓練データが1枚なのでリソースが少なくて嬉しいのと、実写画像でも調和的な編集や画像合成ができる、というのが気になって読んでみた。 メタ情報は次の通り。

1. 概要

1枚の画像に対して様々な操作を実現する手法。 操作の種類は、Paint to Image、Editing、Harmonization、Super Reslution、Single Image Annimation の5つ。 画像をスケールアップさせるGANでピラミッドを構成し、粗い画像から段階的に精巧な画像を生成するように訓練する。 テスト時は入力画像とスケールを調整することで、様々な画像の操作を実現する。

f:id:Shoto:20191207131728p:plain

2. 先行研究との差異

従来のGANは特定の操作のためにデザインされているが、本手法は多くの異なる画像操作が可能である。 SinGANと同様、単一の画像を扱うGANはあったが条件付き(特定画像から別画像への変換)であった。 そのため、本手法では条件なし(ノイズから画像への変換)を可能にしている。 また、従来のGANでは実写画像が扱えなかった点も本手法で克服している。

3. 手法

従来のGANと異なりSinGAN では、画像セットではなく1つの画像からパッチセットを作成して訓練に利用する。 またGANのピラミッドにしたモデルにより、複数の異なるスケールの画像から複雑な構造を獲得する。

f:id:Shoto:20191207131732p:plain

モデル構造の概要は上図の通り。 スケール n で、生成器 G_n\tilde{x}_n を生成する。 このとき G_n は、識別器 D_n が訓練画像をスケールダウンした画像 x_n と区別できないように \tilde{x}_n を生成する。 また x_n\tilde{x}_n を区別する際に比較するのは、画像全体ではなく両者で重なり会うパッチごとである。 パッチサイズは画像がスケールアップされるにつれて小さくなる(右端の黄色矩形)。 スケール n からスケール n-1 になる際、 \tilde{x}_n をスケールアップしてノイズと共に G_{n-1} に入力し、 \tilde{x}_{n-1} を生成する。 ただし n=N の場合のみ、\tilde{x}_N は ノイズ z_N から生成する。

f:id:Shoto:20191207131733p:plain

G_n の具体的な処理は上図のようになり数式(3)で表される。 ψ_nConv(3x3)-BatchNorm-LeakyReLu による畳み込みブロックを5つ連結している。

f:id:Shoto:20191207135410p:plain

Lossは式(4)のようになる。

f:id:Shoto:20191207135126p:plain

前半はいわゆるGANの生成器と識別器の関係。 後半は式(5)で示され、スケールアップした画像から生成した画像と、スケールダウンした訓練画像のRMSE(root mean squared error)となる。

f:id:Shoto:20191207135412p:plain

n < N の時はノイズを利用しないが、n=N の時はノイズを利用するため次の式になる。

f:id:Shoto:20191207135415p:plain

4. 評価

4.1~4.3でSinGAN自体の能力が分かるランダム画像生成、4.4でEditingとHarmonizationの画像操作の評価について見ていく。

4.1. 異なるスケールから生成した画像の違い

f:id:Shoto:20191207131737p:plain

元画像をスケールダウンして 粗めのスケール から入力することで、元画像に似た画像が生成される。 入力するスケール n に応じて、異なる画像が生成される。 下図では、最も粗い n=N と、それに続く n=N-1n=N-2 から入力した例を示している。 nが小さくなるにつれて、変化が小さくなっている。 n=N では大枠が捉えているが違和感があり、n=N-1 では微妙な変化となり、n=N-2 では拡大しないと分からないほどの変化となる。 また、生成画像をパッチごとに識別機にかけているので、反射や影の出力が自然に実現できる。 維持したいコンテキストによって入力するスケールを変えるとよい。

4.2. 生成画像の定性評価

f:id:Shoto:20191207131744p:plain

スケール NN-1、元画像と生成画像のpairedと生成画像のunpairedで、リアルかフェイクかを人が見分ける実験を実施。 50%が完全に混乱するという意味。 NN-1 では後者、paired と unpaired でも後者の方が、生成画像をリアルと判断する傾向があった。 先の画像のように、N-1 のコンテンツはほぼ完全に維持されるため変化する箇所が微妙であり、unpairedは元画像がないので素直な結果と言える。

4.3. 生成画像の定量評価

f:id:Shoto:20191207131747p:plain

GANの生成画像の評価指標に、FID(Frechet Inception Distance) がある。 FIDは画像間の距離を測ることができるが、複数画像のペアが対象となる。 そこで本論文では、元画像と生成画像が1枚ずつのペアでもFIDが利用できるSIFID(Single Image FID)を提案して評価を行っている。 SIFIDは N が 0.09 、N-1 が 0.05 (低い方がよい)となり、定性評価と同じく N-1 の方が元画像に近いことが分かる(左から2番目の列)。 また、SIFIDと定性評価との相関があることも示されている(右端の列)。

4.4 画像操作:Editing, Harmonization

色々な操作ができるが、個人的な興味からEditingとHarmonizationのみに絞って見ていく。

Editing

f:id:Shoto:20191207131739p:plain

編集して縮小した画像を入力する。 PhotoshopのContent Aware MoveよりもSinGANの方が、つなぎ目がシームレスになっている。 粗い画像を拡大しながら精巧にしていく過程を考えると出力結果に納得できる。

Harmonization

f:id:Shoto:20191207131743p:plain

縮小した合成画像を入力する。 Deep Painterly Harmonization(DPH)よりも、前景のオリジナリティが保持されている。 スケール数 N を2~4 にすると、前景の構造を保ちつつ背景のスタイルに上手く変換できるらしい (なんとなくDPHもパラメーター調整でいい感じになる気がするが)。

5. 所感

Harmonizationは、Deep Painterly Harmonizationでは実写画像の扱いが難しかったのと、学習しないからか生成画像が粗かったので、もしホントに実写で上手くいったらSinGANの発明者の皆さんに感謝したい気持ちでいっぱいです。 あと、SinGANは本当に色々できるので、そのぶん応用範囲も広いと思う。 まず、HarmonizationができるならStyle Transferもできるし、Editingも一種のStyle Transferみたいな感じがしたで、そっち方面で新たな研究成果が期待できそう。

TensorFlow GPU版をWindows 10にインストール

Window 10にTensorFlowをインストールして使っていたのだが、PyTorchで遊ぶ環境を作っていたら、 (たぶんPython 3.6にしたことが原因だけど)いつの間にかTensorFlowが動かなくなっていたので、再インストールした。 ここに、その手順を備忘録として残しておく。

なお、環境は次の通り。

1. Anaconda3-4.4.0 をインストール

Anacondaのアーカイブ にアクセスして、Anaconda3-4.4.0-Windows-x86_64.exe をダウンロード&インストール。

2. Python3.5 をインストール

Anaconda3-4.4.0により、Python 3.6がインストールされるが、Window 10では、Python 3.6とTensorFlowの相性が良くないらしい。 そのため、Python 3.5 をcondaでインストール。

> conda install python==3.5

3. TensorFlow 1.5をインストール

Build from source on Windowsによると、 CUDA 9とcuDNN 7の組合せの場合、TensorFlow GPU版はv1.5~1.12が使えるとある。 こちらを参考にCUDAとcuDNNのバージョンを確認したところ、 CUDA 9.0cuDNN 7.0.5だった。この組合せの場合は、tensorflow_gpu-1.5.0が無難そうである。 なので、pipでtensorflow_gpu-1.5.0をインストールする。 なお、condaを使うと必ずエラーになるという罠に何度もかかったので注意。pipを使う。

> pip install tensorflow-gpu==1.5

しかし、以下のようなエラーが出る場合がある。

...
AttributeError: '_NamespacePath' object has no attribute 'sort'

これはpipがエラーの原因なので、easy_installpipをインストールし直す。 pipが壊れたのは、condaでPython 3.5にしたのが原因と思われる。やはりconda、良さそうに見えて罠が多い。

> easy_install pip

これでpipが使えるようになったので、再度pipでtensorflow_gpu-1.5.0をインストールする。

> pip install tensorflow-gpu==1.5

次のようにDEPRECATIONが出るが、とりあえずOK。

...
`DEPRECATION: Uninstalling a distutils installed project (html5lib) has been deprecated and will be removed in a future version. This is due to the fact that uninstalling a distutils project will only partially uninstall the project.`
...

4. Numpy 1.14をインストール

Pythonを起動して、TensorFlowimportすると、またしてもエラーが出る。

>>> import tensorflow as tf
...
ImportError: cannot import name 'multiarray'

これはnumpyのエラーなのだが、こちらのIssueによると、 TensorFlow 1.6以上は、numpy 1.13.3以上である必要がある、とのこと。 TensorFlow 1.5だが、とりあえずcondanumpy 1.14を入れてみる。

> conda install numpy==1.14

これでOKのはず。

5. TensorFlowの動作確認

最終的には以下のように、これまでインストールしてきたモノとバージョンが確認できればOK。

> python
Python 3.5.0 |Anaconda 4.4.0 (64-bit)| (default, Dec  1 2015, 11:46:22) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> import tensorflow as tf
>>> tf.__version__
'1.5.0'
>>>

参考文献