automatik

Cross the Rubicon

PyTorch Getting Started : Data Loading and Processing Tutorials

来年から所属する研究室ではPyTorchが主流らしいため、PyTorchを使い始めることに決めた。一連の記事では Welcome to PyTorch Tutorials — PyTorch Tutorials 1.0.0.dev20180918 documentationのGetting Startedの内容をまとめ、PyTorchの使い方を見ていくことにする。 この記事では Data Loading and Processing Tutorial — PyTorch Tutorials 1.0.0.dev20180918 documentation について解説する。ソースコードはPyTorch Tutorialと同じだけど、ちょくちょく解説入れています。

DATA LOADING AND PROCESSING TUTORIAL

必要なライブラリをimportする。

from __future__ import print_function, division
import os
import torch
import pandas as pd
from skimage import io, transform
import numpy as np
import matplotlib.pyplot as plt
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms, utils

# Ignore warnings
import warnings
warnings.filterwarnings("ignore")

データセットは以下のような形でアノテーションされている。これから出て来る imageアノテーションされていない顔単体の画像であり, landmarksアノテーション部分のことを指す。

face2.jpg

これらの画像とアノテーションデータのcsvここから手に入る。これらを作業中のホームディレクトリにおいている前提で以下は続ける。

csvファイルのアノテーションデータと画像を表示するスクリプトは以下。 画像の上にアノテーションデータを重ねる感じ。

landmarks_frame = pd.read_csv('faces/face_landmarks.csv')

n = 65
img_name = landmarks_frame.iloc[n, 0]
landmarks = landmarks_frame.iloc[n, 1:].as_matrix()

# -1をつけると, 他の指定した行列のサイズから変形すべき大きさを推測してくれる。
landmarks = landmarks.astype('float').reshape(-1, 2)

print('Image name: {}'.format(img_name))
print('Landmarks shape: {}'.format(landmarks.shape))
print('First 4 Landmarks: {}'.format(landmarks[:4]))

def show_landmarks(image, landmarks):
    """Show image with landmarks"""
    plt.imshow(image)
    # 0番目に横軸, 1番目にy軸のデータが入っている
    plt.scatter(landmarks[:, 0], landmarks[:, 1], s=10, marker='.', c='r')
    plt.pause(0.001)  # pause a bit so that plots are updated

plt.figure()
show_landmarks(io.imread(os.path.join('faces/', img_name)),
               landmarks)
plt.show()

ここまで実行すると以下のアノテーションデータが見られる。

face_65.jpg

DATASET CLASS

PyTorchでは torch.utils.data.Dataset という抽象クラスが用意されている。このクラスを継承し、__len__ をデータセットのサイズを返すように、__getitem__ をデータセットから一つサンプルを返すようにオーバーライドすることで簡単にデータセットクラスを作ることができる。画像などは全てのデータを一気にメモリにのせると、あっという間にメモリエラーが起こるのだが、このデータセットクラスで管理してあげると、必要な分をサンプルして返してくれるようになる。

この例ではサンプルして返す値を辞書型にしていて、それが一番使いやすそうではある。公式のドキュメント をみる感じでは、返すのはデータセットからサンプルされるものであればなんでも良さそうである。(辞書型との指定はない)

class FaceLandmarksDataset(Dataset):
    """Face Landmarks dataset."""

    def __init__(self, csv_file, root_dir, transform=None):
        """
        Args:
            csv_file (string): Path to the csv file with annotations.
            root_dir (string): Directory with all the images.
            transform (callable, optional): Optional transform to be applied
                on a sample.
        """
        self.landmarks_frame = pd.read_csv(csv_file)
        self.root_dir = root_dir
        self.transform = transform

    def __len__(self):
        return len(self.landmarks_frame) # データセットのサイズを返す

    def __getitem__(self, idx):
        img_name = os.path.join(self.root_dir,
                                self.landmarks_frame.iloc[idx, 0])
        image = io.imread(img_name)
        landmarks = self.landmarks_frame.iloc[idx, 1:].as_matrix()
        landmarks = landmarks.astype('float').reshape(-1, 2)
        sample = {'image': image, 'landmarks': landmarks}

        if self.transform: # のちに使う
            sample = self.transform(sample)

        return sample # データセットからsampleした値を返す。

このデータセットクラスを使って、最初の4人分をサンプルしてみよう。ソースコードは以下の感じ。

face_dataset = FaceLandmarksDataset(csv_file='faces/face_landmarks.csv',
                                    root_dir='faces/')

fig = plt.figure()

for i in range(len(face_dataset)):
    # __getitem__をオーバーライドしているので、配列にアクセスしているように書ける。直感的。
    sample = face_dataset[i]

    print(i, sample['image'].shape, sample['landmarks'].shape)

    ax = plt.subplot(1, 4, i + 1)
    plt.tight_layout()
    ax.set_title('Sample #{}'.format(i))
    ax.axis('off')
    show_landmarks(**sample)

    if i == 3:
        plt.show()
        break

faces.jpg

TRANSFORMS

ここまででデータセットからサンプルできるようになったが、問題がある。データセットのそれぞれの画像でサイズがバラバラであるのに対し、ほとんどのニューラルネットでは固定長のサイズを入力とする点である。そこで、前処理を行ってデータのサイズを揃えたい。今回行う前処理は以下の3つである。

  • Rescale : 画像のアスペクト比を保ったままサイズを変更する
  • RandomCrop : 画像からランダムに抜き出す(Data augumentationに使う)
  • ToTensor : numpyからpytorchのTensorへ変換する

実装としては __call__ にデータセットクラスでサンプルした値をいれてあげて、その値に対してどういう変換を行うか?を順に書いていけばいい。

Rescaleクラス

class Rescale(object):
    """Rescale the image in a sample to a given size.

    Args:
        output_size (tuple or int): Desired output size. If tuple, output is
            matched to output_size. If int, smaller of image edges is matched
            to output_size keeping aspect ratio the same.
    """

    def __init__(self, output_size):
        assert isinstance(output_size, (int, tuple))
        self.output_size = output_size

    def __call__(self, sample):
        image, landmarks = sample['image'], sample['landmarks']

        h, w = image.shape[:2]
        if isinstance(self.output_size, int):
            # アスペクト比を保ちながら変更する
            if h > w:
                new_h, new_w = self.output_size * h / w, self.output_size
            else:
                new_h, new_w = self.output_size, self.output_size * w / h
        else:
            new_h, new_w = self.output_size # 出力がタプルの時、タプルの指定するサイズへ変更する

        new_h, new_w = int(new_h), int(new_w)

        img = transform.resize(image, (new_h, new_w))

        # h and w are swapped for landmarks because for images,
        # x and y axes are axis 1 and 0 respectively
        landmarks = landmarks * [new_w / w, new_h / h]

        return {'image': img, 'landmarks': landmarks}

Random Cropクラス

class RandomCrop(object):
    """Crop randomly the image in a sample.

    Args:
        output_size (tuple or int): Desired output size. If int, square crop
            is made.
    """

    def __init__(self, output_size):
        assert isinstance(output_size, (int, tuple))
        if isinstance(output_size, int):
            self.output_size = (output_size, output_size)
        else:
            assert len(output_size) == 2
            self.output_size = output_size

    def __call__(self, sample):
        image, landmarks = sample['image'], sample['landmarks']

        h, w = image.shape[:2]
        new_h, new_w = self.output_size
        
        # out of rangeに注意してCropする
        top = np.random.randint(0, h - new_h)
        left = np.random.randint(0, w - new_w)

        image = image[top: top + new_h,
                      left: left + new_w]

        landmarks = landmarks - [left, top]

        return {'image': image, 'landmarks': landmarks}

ToTensorクラス
公式のコメントにある通り、Torchでは画像は C * H * Wで持つようで、他のライブラリが H * W * C で持つことが多いので注意が必要だなと感じた。

class ToTensor(object):
    """Convert ndarrays in sample to Tensors."""

    def __call__(self, sample):
        image, landmarks = sample['image'], sample['landmarks']

        # swap color axis because
        # numpy image: H x W x C
        # torch image: C X H X W
        image = image.transpose((2, 0, 1))
        return {'image': torch.from_numpy(image),
                'landmarks': torch.from_numpy(landmarks)}

Composed Transforms

これらを適用すると以下のようになる。

scale = Rescale(256)
crop = RandomCrop(128)
composed = transforms.Compose([Rescale(256),
                               RandomCrop(224)])

# Apply each of the above transforms on sample.
fig = plt.figure()
sample = face_dataset[65]
for i, tsfrm in enumerate([scale, crop, composed]):
    transformed_sample = tsfrm(sample)

    ax = plt.subplot(1, 3, i + 1)
    plt.tight_layout()
    ax.set_title(type(tsfrm).__name__)
    show_landmarks(**transformed_sample)

plt.show()

同じスケールで表示されてわかりづらいが、この画像のサイズではRescaleした段階では横軸と縦軸で値が違う点に注意。

face_65_rescale_crop.jpg

ITERATING THROUGH THE DATASET

実際にこれらをデータセットクラスと一緒に使ってみる。まとめると、データセットからサンプルされるたびに

  • 画像が直ちにサンプルされる
  • Transform(上で作った変換処理)がサンプルされた画像に適用される
  • RandomCropクラスがランダム処理を挟むので、サンプルされると水増しされる
transformed_dataset = FaceLandmarksDataset(csv_file='faces/face_landmarks.csv',
                                           root_dir='faces/',
                                           transform=transforms.Compose([
                                               Rescale(256),
                                               RandomCrop(224),
                                               ToTensor()
                                           ]))

for i in range(len(transformed_dataset)):
    sample = transformed_dataset[i]

    print(i, sample['image'].size(), sample['landmarks'].size())

    if i == 3:
        break

出力をみるとわかるように、同じ大きさでサンプリングされていることが分かる。

0 torch.Size([3, 224, 224]) torch.Size([68, 2])
1 torch.Size([3, 224, 224]) torch.Size([68, 2])
2 torch.Size([3, 224, 224]) torch.Size([68, 2])
3 torch.Size([3, 224, 224]) torch.Size([68, 2])

しかし、このままではDeep Learningする上で必要なミニバッチに分ける処理、データをシャッフルする処理、multiprocessing を用いて並列でデータをロードする処理が入っていない。その辺りは、 torch.utils.data.DataLoader を用いて行うようである。データセットクラスはデータセット自体の定義、Transformは前処理の変換、DataLoaderはデータセットクラスからデータを引き出す時に行う処理と考えたらよさそう(?)。

transform入りのデータセットクラスのインスタンスと、バッチサイズやシャッフルの有無などを引数で渡してあげれば良い。 結構引数のオプションがある。ここ を参照。

dataloader = DataLoader(transformed_dataset, batch_size=4,
                        shuffle=True, num_workers=4)


# Helper function to show a batch
def show_landmarks_batch(sample_batched):
    """Show image with landmarks for a batch of samples."""
    images_batch, landmarks_batch = \
            sample_batched['image'], sample_batched['landmarks']
    batch_size = len(images_batch)
    im_size = images_batch.size(2)

    grid = utils.make_grid(images_batch)
    plt.imshow(grid.numpy().transpose((1, 2, 0)))

    for i in range(batch_size):
        plt.scatter(landmarks_batch[i, :, 0].numpy() + i * im_size,
                    landmarks_batch[i, :, 1].numpy(),
                    s=10, marker='.', c='r')

        plt.title('Batch from dataloader')

for i_batch, sample_batched in enumerate(dataloader):
    print(i_batch, sample_batched['image'].size(),
          sample_batched['landmarks'].size())

    # observe 4th batch and stop.
    if i_batch == 3:
        plt.figure()
        show_landmarks_batch(sample_batched)
        plt.axis('off')
        plt.ioff()
        plt.show()
        break

004.jpg

AFTERWORD: TORCHVISION

今回は データセットクラスの定義と諸々の前処理を書いてきたけれど、毎回同じような処理を書くのはしんどいということで、torchvision にその辺りはまとまっておいてあるようだ。特に分類問題の時などはサッと書けるようにしてあるのか、ディレクトリを以下のように 分類したいクラス/画像 という風に作ってあげると ImageFolder クラスがよしなにやってくれるようだ。クラスの順番はアルファベット順で target 側は帰って来ると思われる。この辺を読んでいる限りでは..。

root/ants/xxx.png
root/ants/xxy.jpeg
root/ants/xxz.png
.
.
.
root/bees/123.jpg
root/bees/nsdf3.png
root/bees/asd932_.png

ImageFolderの使い方は以下。

import torch
from torchvision import transforms, datasets

data_transform = transforms.Compose([
        transforms.RandomSizedCrop(224),
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406],
                             std=[0.229, 0.224, 0.225])
    ])
hymenoptera_dataset = datasets.ImageFolder(root='hymenoptera_data/train',
                                           transform=data_transform)
dataset_loader = torch.utils.data.DataLoader(hymenoptera_dataset,
                                             batch_size=4, shuffle=True,
                                             num_workers=4)

このあたりの使い方は明日、Transfer Learning(転移学習) の項目でじっくり見ていくことにする。