🐍

[Pytorch]じゃんけん画像を分類してみた

2022/11/25に公開

YOLOで使えそうな物体検出用のデータセットを探していたとき、roboflow で偶然面白そうな画像分類のデータセットを発見しました。じゃんけんの手の画像から、「グー」、「チョキ」、「パー」を予測するというものです。
実際にディープラーニングのモデルを作成し、予測を行ってみました。
https://public.roboflow.com/classification/rock-paper-scissors

データのロード

上記のリンクから直接ダウンロードしてもいいのですが、私は以下のコマンドで zip形式でダウンロードして解凍しました。

curl -L "https://public.roboflow.com/ds/hfUFTOf07d?key=ZD4TxpUszt" > roboflow.zip; unzip roboflow.zip; rm roboflow.zip

ディレクトリ構造

ダウンロード後のディレクトリ構造です。

root
├── RockPaperScissors.ipynb
└── data
    └── RockPaperScissors
        ├── test
        │   ├── paper
        │   ├── rock
        │   └── scissors
        ├── train
        │   ├── paper
        │   ├── rock
        │   └── scissors
        └── valid
            ├── paper
            ├── rock
            └── scissors

ここから実装

Pytorchで実装したいので以下のモジュールを使います。

import numpy as np
import os
import glob
import random
import seaborn as sns
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix
from PIL import Image 
from tqdm import tqdm

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torchvision
from torchvision import models, transforms
from torch.utils.data import Dataset

乱数の初期化をおこないます。再現性を持たせたいためシードは固定化しました。

torch.manual_seed(1234)
np.random.seed(1234)
random.seed(1234)

データの取得

get_image_path_list 関数で RockPaperScissorsデータセットの画像のパスのリストを取得します。また、引数の phase='train' で訓練(train)・検証(valid)・テスト(valid)のどの一覧を取得するのか指定できるようにしました。

# 画像のパスのリストを取得する関数
def get_image_path_list(root_path, phase='train'):
    target_path_jpg = os.path.join(root_path, phase, '**/*.jpg')
    target_path_png = os.path.join(root_path, phase, '**/*.png')
    img_path_list = glob.glob(target_path_jpg) + glob.glob(target_path_png)
    return img_path_list

root_path = './data/RockPaperScissors'
train_list = get_image_path_list(root_path, phase='train')
valid_list = get_image_path_list(root_path, phase='valid')
test_list = get_image_path_list(root_path, phase='test')

print("train image : {} images".format(len(train_list)))
print("valid image : {} images".format(len(valid_list)))
print("test image : {} images".format(len(test_list)))
train image : 2520 images
valid image : 372 images
test image : 33 images

実行結果から

  • 訓練: 2520枚
  • 検証: 372枚
  • テスト: 33枚

だとわかります。

データをのぞいてみる

訓練データの一つ目を確認してみます。

# 訓練サンプルの一つ目
print("train_list[0] : ", train_list[0])
img = Image.open(train_list[0])
plt.imshow(img)
plt.show()

画像は 300 × 300 で背景は白みたいですね。

前処理

torchvision の Transform オブジェクトを作成して前処理を行います。
訓練データだけ異なる変換を行いました。

  • RandomResizedCrop : ランダムに切り抜いてリサイズ
  • RandomHorizontalFlip : ランダムに左右反転
  • RandomRotation : ランダムに回転
  • ToTensor : torch.Tensor に変換 ※[0,255]→[0,1]となる
  • Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5])

※ Nomalize は通常標準化を行うために用いると思うのですが、今回は μ=0.5, σ=0.5 を指定しているため単純に[0,1] を [-1.0,1.0] にスケーリングするために用いています。本来は訓練データ全体における平均と標準偏差を求める必要がありますが、めんどうなのでスケーリングにしてます(笑)。

標準化の式は下のようになりますが、

μ = 平均, σ = 標準偏差
x' = \frac{x-μ}{σ}

μ=0.5, σ=0.5 を代入すると、

x' = 2x-1

この式から、[0,1] を [-1.0,1.0] にスケーリングされることが確認できました。では実装してみます

MyTransform

前処理を行うための MyTransformクラスを作成しました。 MyTransformオブジェクトが作成されるとコンストラクタで transform を初期化するだけの処理がされます。__call__メソッドによって、オブジェクトを画像を引数として呼び出すと変換後の画像が返されます。

class MyTransform():
    
    def __init__(self, img_size):
        self.data_transform = {
            "train": transforms.Compose([
                transforms.RandomResizedCrop(img_size, scale=(0.5, 1.0)),
                transforms.RandomHorizontalFlip(),
                transforms.RandomRotation(degrees=10),
                transforms.ToTensor(),
                transforms.Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5])
            ]),
            
            "valid": transforms.Compose([
                transforms.Resize(img_size),
                transforms.CenterCrop(img_size),
                transforms.ToTensor(),
                transforms.Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5])
            ]),
            
            "test": transforms.Compose([
                transforms.Resize(img_size),
                transforms.CenterCrop(img_size),
                transforms.ToTensor(),
                transforms.Normalize(mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5])
            ])
        }
    
    def __call__(self, img, phase="train"):
        return self.data_transform[phase](img)

訓練データの1つ目で実際に変換されているか確認してみます。

img_size = 300
transform = MyTransform(img_size)
img_transformed = transform(img, phase="train")
img_transformed = img_transformed.numpy().transpose((1,2,0))
img_transformed = np.clip(img_transformed, 0, 1)
print(img_transformed.shape)
plt.imshow(img_transformed)
plt.show()

変換できていました。

前処理前と後の比較

1枚だけだとわかりずらかったため、グー、チョキ、パーそれぞれ3枚ずつ前処理を行って比較してみました。前処理するかしないか選ぶため、引数に transform オブジェクトをとるようにしています。

def show_images(transform=None):
    row = 3
    col = 3
    fig, ax = plt.subplots(nrows=row, ncols=col, figsize=(8,8))
    #img_show_index = [839, 1679, 2519]
    img_show_index = [400, 1200, 2000]
    
    for i, index in enumerate(img_show_index):
        if i==0:
            title="scissors"
        elif i==1:
            title="rock"
        elif i==2:
            title="paper"

        for j in range(row):
            ax[j,i].set_title(title, fontsize=8)
            ax[j,i].axes.xaxis.set_visible(False)
            ax[j,i].axes.yaxis.set_visible(False)
            img = Image.open(train_list[index+(j*10)])
            
            if transform != None:
                img_transformed = transform(img, phase="train")
                img_transformed = img_transformed.numpy().transpose((1,2,0))
                img_transformed = np.clip(img_transformed, 0, 1)
                ax[j,i].imshow(img_transformed)
            else:
                ax[j,i].imshow(img)

前処理する前

show_images()
plt.savefig('image_show.png')

いろいろなじゃんけんの手がみられます。チョキは3本指の方なんですね!

前処理した後

show_images(transform=MyTransform(300))
plt.savefig('image_show_transformed.png')

前処理はしっかりできていることが確認できました。しかし、変換後は影のようになってしまっています。
これは np.clip で [0,1] に抑えたために起きています。Normalize の前処理によって [-1,1] だったものが、-1 ~ 0 の範囲が全て 0 に抑え込まれていることが考えられます。実際はもっと影は少なくなるとは思いますが、imshow()のために仕方なく抑えています。
もっと良いやり方があるとはおもいます(笑)。

Dataset

Dataset を作成する必要があります。以下のようにオリジナルの RockPaperScissorsDataset クラスを作成しました。以前作成したパスのリストと transform オブジェクトからデータとラベルを返します。

class RockPaperScissorsDataset(Dataset):
    
    def __init__(self, img_path_list, transform=None, phase='train'):
        self.img_path_list = img_path_list
        self.transform = transform
        self.phase = phase
    
    def __len__(self):
        return len(self.img_path_list)
    
    def __getitem__(self, index):
        # indexの画像の取得
        img_path = self.img_path_list[index]
        img = Image.open(img_path)
        # 前処理された画像の取得
        img_transformed = self.transform(img, phase=self.phase)
        # labelの取得
        label_names = ["rock", "paper", "scissors"]
        for i, name in enumerate(label_names):
            if name in img_path[27:]:
                label = i
        return img_transformed, label

phaseでモードを指定して各々のデータセットを作ります。訓練の一つ目のデータも確認しています。

train_dataset = RockPaperScissorsDataset(img_path_list=train_list, transform=MyTransform(img_size=300), phase='train')
valid_dataset = RockPaperScissorsDataset(img_path_list=valid_list, transform=MyTransform(img_size=300), phase='valid')
test_dataset = RockPaperScissorsDataset(img_path_list=test_list, transform=MyTransform(img_size=300), phase='test')

print(train_dataset.__getitem__(0)[0].size())
print(train_dataset.__getitem__(0)[1])

しっかり[C, W, H] の形になっていて、ラベルの形も良いことが確認できました。

torch.Size([3, 300, 300])
2

DataLoader

後で学習を行いたいためDataLoaderを作成しました。batch_size は 16 にしてデータローダーを作成し、辞書型の変数にまとめました。

batch_size = 16
train_dataloader = torch.utils.data.DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
valid_dataloader = torch.utils.data.DataLoader(valid_dataset, batch_size=batch_size, shuffle=False)
test_dataloader = torch.utils.data.DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

dataloader_dict = {"train": train_dataloader, "valid": valid_dataloader, "test": test_dataloader}

ディープラーニングモデルを作成

既存のモデルをロードして転移学習やファインチューニングでもよかったのですが、今回はnn.Modelu を継承してオリジナルの MyNet クラスを作成しました。私の環境だと GPU のメモリサイズが 4GiB なので大規模なモデルは使えませんでした。そこで、パラメータ数を抑えたモデルを作成しています。

  • 畳み込み層 → MaxPooling → Dropout を 4 回繰り返したあと、全畳み込み層を 1 つ置いて分類します。
class MyNet(nn.Module):
    def __init__(self, num_classes):
        super(MyNet, self).__init__()
        self.features = nn.Sequential(
	
            nn.Conv2d(in_channels=3, out_channels=64, kernel_size=6, stride=2), # in: 300, out: 148
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2), # in: 148, out: 74
            nn.Dropout(0.5),
            
            nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, stride=1), #in: 74, out: 72
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2), # in: 72, out: 36
            nn.Dropout(0.5),
            
            nn.Conv2d(in_channels=128, out_channels=128, kernel_size=3, stride=1), #in: 36, out: 34
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2), # in: 34, out: 17
            nn.Dropout(0.5),
            
            nn.Conv2d(in_channels=128, out_channels=128, kernel_size=2, stride=1), # in: 17, out: 16
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2),
            nn.Dropout(0.5)
        )
        self.classifier = nn.Linear(in_features=8*8*128, out_features=num_classes) 
        
    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)
        x = self.classifier(x)
        return x

ランダムな 10 個のデータを用意して順伝播の処理がうまくいっているか確認します。

net = MyNet(num_classes=3)
x = torch.randn(10, 3, 300, 300)
y = net(x)
print(y.size())

順伝播はうまくいってました。

torch.Size([10, 3])

ついでにモデルのパラメータ数を確認しておきました。

from torchinfo import summary
summary(net)
=================================================================
Layer (type:depth-idx)                   Param #
=================================================================
MyNet                                    --
├─Sequential: 1-1                        --
│    └─Conv2d: 2-1                       6,976
│    └─ReLU: 2-2                         --
│    └─MaxPool2d: 2-3                    --
│    └─Dropout: 2-4                      --
│    └─Conv2d: 2-5                       73,856
│    └─ReLU: 2-6                         --
│    └─MaxPool2d: 2-7                    --
│    └─Dropout: 2-8                      --
│    └─Conv2d: 2-9                       147,584
│    └─ReLU: 2-10                        --
│    └─MaxPool2d: 2-11                   --
│    └─Dropout: 2-12                     --
│    └─Conv2d: 2-13                      65,664
│    └─ReLU: 2-14                        --
│    └─MaxPool2d: 2-15                   --
│    └─Dropout: 2-16                     --
├─Linear: 1-2                            24,579
=================================================================
Total params: 318,659
Trainable params: 318,659
Non-trainable params: 0
=================================================================

パラメーラ数は 318,659 と少ないです。VGG16のパラメータ数 が 138357544 ですので、かなり小規模なモデルであることがわかります。

損失関数、最適化

3クラスの分類なので CrossEntropyLoss, 最適化には SGD を用いました。

criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(params=net.parameters(), lr=0.001)

学習

学習させるための関数

学習する関数を作成して実際に学習させます。プログレスバーは tqdm を用いています。戻り値として各エポックの認識率と損失をまとめたリストを返すようにしました。

def train(net, dataloader_dict, criterion, optimizer, num_epochs):
    #plot用
    accs = {'train': [], 'valid': []}
    losses = {'train': [], 'valid': []}
    
    device = "cuda:0" if torch.cuda.is_available() else "cpu"
    net.to(device)
    
    for epoch in range(num_epochs):
        print("Epoch {}/{}".format(epoch+1, num_epochs))
        
        for phase in ['train', 'valid']:
            if phase=='train':
                net.train()
            elif phase=='valid':
                net.eval()
            epoch_loss = 0.0
            epoch_acc = 0.0
            
            if (epoch==0) and (phase=='train'):
                continue
            
            for inputs, labels in tqdm(dataloader_dict[phase]):
                inputs = inputs.to(device)
                labels = labels.to(device)
                
                with torch.set_grad_enabled(phase=='train'):
                    optimizer.zero_grad()
                    outputs = net(inputs)
                    loss = criterion(outputs, labels)
                    pred = torch.argmax(outputs, dim=1)
                    
                    if phase == 'train':
                        loss.backward()
                        optimizer.step()
                    
                    epoch_loss += loss.item()
                    epoch_acc += torch.mean(pred.eq(labels).float()) 
     
            epoch_loss /= len(dataloader_dict[phase])
            epoch_acc /= len(dataloader_dict[phase])         
            epoch_acc = epoch_acc.cpu()
	    
            accs[phase].append(epoch_acc)
            losses[phase].append(epoch_loss)
         
            print("{} Loss: {:.4f}, {} accuracy: {:.4f}".format(phase, epoch_loss, phase, epoch_acc))
    return accs, losses

学習

エポック数は 200 にしてます。学習します。

num_epochs = 200
accs, losses = train(net, dataloader_dict, criterion, optimizer, num_epochs=num_epochs)

認識率の変化

plt.style.use('ggplot')
plt.plot(accs['train'], label='train acc')
plt.plot(accs['valid'], label='validation acc')
plt.legend()
plt.savefig('acc.png')
plt.show()

損失の変化

plt.style.use('ggplot')
plt.plot(losses['train'], label='train loss')
plt.plot(losses['valid'], label='validation loss')
plt.legend()
plt.savefig('loss.png')
plt.show()

学習済みモデルの保存

save_path = './mynet_200epochs.pth'
torch.save(net.state_dict(), save_path)

テスト

テストデータも 33 データ用意されていたためテストデータに関しても推論させます。関数内で損失と認識率を出力し、何で間違っていたのか後で確認したかったため、戻り値には 33 データ分の予測とラベルを返すようにしました。

def test(net, test_dataloader, criterion):
    device = "cuda:0" if torch.cuda.is_available() else "cpu"
    net.to(device)
    net.eval()
    
    test_loss = 0.0
    test_corrects = 0.0
    test_preds = []
    test_labels = []
    with torch.no_grad():
        for inputs, labels in test_dataloader:
            inputs = inputs.to(device)
            labels = labels.to(device)

            outputs = net(inputs)
            loss = criterion(outputs, labels)
            preds = torch.argmax(outputs, dim=1)
            test_preds.extend(preds.tolist())
            test_labels.extend(labels.tolist())
            
            test_loss += loss.item() * inputs.size(0)
            test_corrects += torch.sum(preds==labels.data)
    
    test_loss /= len(test_dataloader.dataset)
    test_corrects /= len(test_dataloader.dataset)
    print("test Loss: {:.4f}, accuracy: {:.4f}".format(test_loss, test_corrects))
    return test_preds, test_labels

実際にテスト

test_preds, test_labels = test(net, test_dataloader, criterion)

結果

精度はあまり良くはなさそうですが、データのサンプル数が少ないのとハイパーパラメータのチューニングを行っていないため妥当かもしれません。

test Loss: 0.3286, accuracy: 0.7879
  • 予測ラベル
print(test_preds)
# [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 0, 0, 2, 0, 0, 0, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2]
  • 正解ラベル
print(test_labels)
# [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

どう間違っていたのか

グー、チョキ、パーのうちどう間違っていたのか確認しました。混合行列を見てみます。

conf_matlix = confusion_matrix(test_labels, test_preds)
conf_matlix = pd.DataFrame(data=conf_matlix, index=["Rock", "Paper", "Scissors"],
                           columns=["Rock", "Paper", "Scissors"])
sns.heatmap(conf_matlix, annot=True, cmap='Blues')
plt.xlabel("Predict", fontsize=15)
plt.ylabel("True", fontsize=15)
plt.savefig('conf_matlix.png')

正解は「グー」なのに「チョキ」と分類されたものが5つあります。もしかすると「グー」を分類することは難しかったのかもしれません。例えば、テストデータで間違っていたものには次のような画像が含まれていました。

チョキに似ていない気がしないでもないです。グーとチョキのドメインが似ていたからなのか、単純にモデルの学習の部分が悪いのかは定かではありませんがこのデータセットにおいては「グー」を分類するのは難しそうです。

最後に

YOLO の物体検出用の画像を探していたはずが、気が付いたらじゃんけんの画像分類をしていました。roboflow には他にも楽しそうなデータセットが公開されているので画像認識が好きな人や、挑戦してみたい人はぜひ試してみてください。ではまた!🚀🚀
https://public.roboflow.com/

Discussion