ホーム エンジニア AI 機械学習によるアイコン画像生成のこころみ (実践編)
機械学習によるアイコン画像生成のこころみ (実践編)
 

機械学習によるアイコン画像生成のこころみ (実践編)

実践編では、機械学習の手法によるアイコン画像の生成について書いていきます。

前回の下調べ編では CycleGAN でのスタイル変換によるアイコン画像生成の先例を見ていきました。今回はその再現を実践していきます。

実践にあたっては以下を順に見ていきます。

  1. 実装
  2. データセット
  3. 実行環境

1.実装

実装は前述のとおり junyanz/pytorch-CycleGAN-and-pix2pix を利用したとのことなのでこれを利用します。データセットを所定のディレクトリに配置し学習します。

おそらく以下のような実行方法になります。

# 32x32 のデータセットを配置し、そのデータセットでの学習 (4つの GPU があるものとする)
python train.py --dataroot ./datasets/iconify32x32 --name iconify_cyclegan --model cycle_gan --gpu_ids 0,1,2,3 --batch_size 16
# 64x64 のデータセットでのファインチューニング
python train.py --dataroot ./datasets/iconify64x64 --name iconify_cyclegan --model cycle_gan --gpu_ids 0,1,2,3 --batch_size 16 --continue_train
# 128x128
python train.py --dataroot ./datasets/iconify128x128 --name iconify_cyclegan --model cycle_gan --gpu_ids 0,1,2,3 --batch_size 16 --continue_train
# 256x256
python train.py --dataroot ./datasets/iconify256x256 --name iconify_cyclegan --model cycle_gan --gpu_ids 0,1,2,3 --batch_size 16 --continue_train

まず最初に解像度の低い画像のデータセットで学習し、だんだん解像度を上げていきます。この実装では --continue_train フラグを指定することで、モデルをファインチューニングできるので、2回目以降の解像度ではファインチューニングとして実行していきます。

2.データセット

ロゴ画像については、400×400 のサイズの画像をリサイズしていけばよさそうです。一方で実写の物体の画像を用意するのはひと手間かかる予感がします。

pycocotools を使って COCO のアノテーション情報をもとに加工していきます。ひとまずどんなライブラリかコンソールで確認していきます。

>>> from pycocotools.coco import COCO
>>>
>>> annotation_path = 'datasets/coco/annotations/instances_train2017.json'
>>> coco = COCO(annotation_path)
loading annotations into memory...
Done (t=23.60s)
creating index...
index created!

画像のリソースや、そのアノテーション情報にアクセスできます。

>>> img_id = coco.getImgIds()[0]
>>> img_id
391895
>>> img = coco.loadImgs(img_id)
>>> img
[{'license': 3, 'file_name': '000000391895.jpg', 'coco_url': 'http://images.cocodataset.org/train2017/000000391895.jpg', 'height': 360, 'width': 640, 'date_captured': '2013-11-14 11:18:45', 'flickr_url': 'http://farm9.staticflickr.com/8186/8119368305_4e622c8349_z.jpg', 'id': 391895}]

画像のリソースの情報にアクセスできます。

なお、ここでの license の項目は画像のライセンスをあらわす定数のようです。完全に脱線してしまいますが、アノテーションファイルの JSON から jq .licenses instances_train2017.json などとしてライセンスの情報をみてみます。

[
  {
    "url": "http://creativecommons.org/licenses/by-nc-sa/2.0/",
    "id": 1,
    "name": "Attribution-NonCommercial-ShareAlike License"
  },
  {
    "url": "http://creativecommons.org/licenses/by-nc/2.0/",
    "id": 2,
    "name": "Attribution-NonCommercial License"
  },
  {
    "url": "http://creativecommons.org/licenses/by-nc-nd/2.0/",
    "id": 3,
    "name": "Attribution-NonCommercial-NoDerivs License"
  },
  {
    "url": "http://creativecommons.org/licenses/by/2.0/",
    "id": 4,
    "name": "Attribution License"
  },
  {
    "url": "http://creativecommons.org/licenses/by-sa/2.0/",
    "id": 5,
    "name": "Attribution-ShareAlike License"
  },
  {
    "url": "http://creativecommons.org/licenses/by-nd/2.0/",
    "id": 6,
    "name": "Attribution-NoDerivs License"
  },
  {
    "url": "http://flickr.com/commons/usage/",
    "id": 7,
    "name": "No known copyright restrictions"
  },
  {
    "url": "http://www.usa.gov/copyright.shtml",
    "id": 8,
    "name": "United States Government Work"
  }
]

上記の画像のリソースの情報は CC BY-NC-ND なので、会社のブログで画像を切ったり貼ったりするのには適さなそうです。CC BY 2.0 の 4 からランダムに選んでみます。

>>> ids = coco.getImgIds()
>>> imgs = coco.loadImgs(ids)
>>> import random
>>> img = random.choice([i for i in imgs if i['license'] == 4])
>>> img
{'license': 4, 'file_name': '000000226571.jpg', 'coco_url': 'http://images.cocodataset.org/train2017/000000226571.jpg', 'height': 426, 'width': 640, 'date_captured': '2013-11-18 21:53:31', 'flickr_url': 'http://farm4.staticflickr.com/3037/3054252115_dca3690eb8_z.jpg', 'id': 226571}
000000226571.jpg

4 of 4 Two Equestrian Horse Riders on Morro Strand State B… | Flickr

馬にのった人物の画像でした。

補足:一応クレジット表示のためにもとの画像の URL もさがしておきます。Find photo URL by filename of jpg によるとファイル名から探せるようなのでさがします。今回は http://flickr.com/photo.gne?id=3054252115 → https://www.flickr.com/photos/mikebaird/3054252115/ で探せました。以上脱線終わりです。

つづいてアノテーションも見てみます。

>>> ann_ids = coco.getAnnIds(imgIds=[img['id']])
>>> ann_ids
[54013, 54104, 185138, 209986, 1376036]
>>> anns = coco.loadAnns(ann_ids)
>>> ann = anns[2]
>>> ann
{'segmentation': [[345.95, 149.26, 345.95, 152.32, 345.95, 155.0, 340.98, 156.91, 330.65, 163.03, 332.18, 164.57, 339.06, 165.33, 334.47, 176.43, 326.82, 187.52, 325.29, 193.64, 326.44, 208.18, 325.29, 223.86, 325.29, 237.64, 342.12, 263.27, 352.07, 272.02, 352.84, 294.97, 359.72, 322.52, 359.72, 332.08, 364.69, 329.79, 365.08, 326.34, 368.14, 314.1, 371.58, 314.48, 372.73, 321.75, 373.88, 327.87, 375.02, 330.93, 382.29, 329.79, 383.06, 329.4, 383.44, 329.4, 384.97, 325.2, 381.91, 324.81, 375.79, 319.07, 373.11, 312.95, 370.43, 303.39, 370.05, 299.18, 370.43, 289.62, 370.43, 267.04, 364.31, 253.27, 358.96, 238.02, 364.31, 233.43, 363.93, 222.72, 371.58, 221.19, 384.59, 220.8, 390.71, 220.04, 389.18, 216.21, 387.65, 212.39, 386.5, 210.09, 376.94, 211.62, 363.93, 212.39, 362.4, 192.88, 366.99, 182.21, 364.31, 182.21, 360.49, 181.83, 356.66, 179.92, 356.66, 178.77, 361.25, 178.77, 362.78, 175.33, 363.55, 173.03, 366.23, 172.65, 367.37, 171.5, 366.61, 166.91, 367.37, 163.47, 368.14, 161.94, 375.02, 163.09, 381.15, 163.09, 380.38, 160.41, 367.76, 155.05, 365.08, 146.25, 352.84, 145.11, 344.42, 147.4]], 'area': 5287.617649999996, 'iscrowd': 0, 'image_id': 226571, 'bbox': [325.29, 145.11, 65.42, 186.97], 'category_id': 1, 'id': 185138}

これだけだとよくわからないのでマスクに変換して形状を見てみます。

>>> mask = coco.annToMask(ann)
>>> mask
array([[0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0],
       ...,
       [0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0]], dtype=uint8)
>>> import numpy as np
>>> from PIL import Image
>>> masking = Image.fromarray(mask * 255) # mask は 0, 1 の numpy.ndarray になっているので 1 の部分を白くするために 255 をブロードキャストします
>>> masking.save('masking.jpg')
masking

このアノテーションは上の画像の人間の部分のようです。

これをつかって切り抜いてみます。

>>> from skimage import io
>>> image = io.imread(img['coco_url'])
>>> image[np.where(mask == 0)] = 255 # マスクで 0 になっている部分 (背景部分) を白くしてしまう
>>> Image.fromarray(image).save('masked-image.jpg')
masked-image

対象領域だけにしぼってみます。すこし煩雑になってきたのでメソッドを定義します。

>>> def object_rect_indices(mask):
...     '''アノテーションのマスクからオブジェクトの矩形領域の両端 (top, bottom, left, right) を返す
...     '''
...     ys, xs, *_ = np.where(mask == 1) # mask は2次元配列の想定
...     top = ys.min()
...     bottom = ys.max()
...     left = xs.min()
...     right = xs.max()
...     return (top, bottom, left, right)
...
>>> def clip_image_margin(image, mask):
...     '''画像の余白を削って返す
...     '''
...     top, bottom, left, right = object_rect_indices(mask)
...     return image[top:bottom, left:right]
...
>>> clipped = clip_image_margin(image, mask)
>>> Image.fromarray(clipped).save('clipped.jpg')
clipped

よさそうです。正方形にしてしまいます。

>>> def to_square(image):
...     '''画像の ndarray を、長辺にあわせて正方形に余白を追加した ndarray を返す
...     '''
...     h, w, *_ = image.shape
...     pad_width = [(0, 0)] * image.ndim # 2値画像なら二次元分、RGB 画像なら三次元分
...     if h > w:
...         # 縦長なので横方向にパディングする
...         size = h
...         padding = size - w
...         before = padding // 2
...         after = padding - before
...         pad_width[1] = (before, after)
...     else:
...         # 横長なので縦方向にパディングする
...         size = w
...         padding = size - h
...         before = padding // 2
...         after = padding - before
...         pad_width[0] = (before, after)
...     padded = np.pad(image, pad_width, constant_values=255) # 余白は白で埋める
...     return padded
...
>>> square_image = to_square(clipped)
>>> Image.fromarray(square_image).save('square-image.jpg')
square-image

こういう画像が用意できたらあとはリサイズしていけばよさそうです。


今回は以下のようなスクリプトにまとめて小さい画像を排除しつつ切り出しとリサイズを行いました。

import numpy as np
from PIL import Image
from pathlib import Path
from pycocotools.coco import COCO

# instances_train2017.json でのアノテーションでは、セグメントのピクセルサイズの
# 中央値が 1696 くらいなので、ひとまず 1500px あれば小さいとはみなさないことにする。
SMALL_IMAGE_PIXEL_THRESHOLD = 1500

# データセット用にリサイズするサイズ
SIZE_32x32 = (32, 32)
SIZE_64x64 = (64, 64)
SIZE_128x128 = (128, 128)
SIZE_256x256 = (256, 256)

def is_satisfied_object_size(mask, min_threshold=SMALL_IMAGE_PIXEL_THRESHOLD):
    '''アノテーションのマスクの ndarray から対象セグメントのサイズが訓練に使えるサイズかどうかを判断して返す
    '''
    return np.size(mask[mask == 1]) >= min_threshold

def extract_object(image, mask):
    '''マスク対象の画像 image の ndarray と、mask の ndarray で、対象のセグメント部分以外を白く塗り潰した ndarray を返す
    '''
    extracted = image.copy()
    extracted[np.where(mask == 0)] = 255 # セグメントのオブジェクト以外の部分を白くする
    return extracted

def object_rect_indices(mask):
    '''アノテーションのマスクからオブジェクトの矩形領域の両端 (top, bottom, left, right) を返す
    '''
    ys, xs, *_ = np.where(mask == 1) # mask は2次元配列の想定
    top = ys.min()
    bottom = ys.max()
    left = xs.min()
    right = xs.max()
    return (top, bottom, left, right)

def clip_image_margin(image, mask):
    '''画像の余白を削って返す
    '''
    top, bottom, left, right = object_rect_indices(mask)
    return image[top:bottom, left:right]

def to_square(image):
    '''画像の ndarray を、長辺にあわせて正方形に余白を追加した ndarray を返す
    '''
    h, w, *_ = image.shape
    pad_width = [(0, 0)] * image.ndim # 2値画像なら二次元分、RGB 画像なら三次元分
    if h > w:
        # 縦長なので横方向にパディングする
        size = h
        padding = size - w
        before = padding // 2
        after = padding - before
        pad_width[1] = (before, after)
    else:
        # 横長なので縦方向にパディングする
        size = w
        padding = size - h
        before = padding // 2
        after = padding - before
        pad_width[0] = (before, after)
    padded = np.pad(image, pad_width, constant_values=255) # 余白は白で埋める
    return padded

def extract_square_object_image(image, mask):
    '''正方形にセグメント対象を切り抜いた ndarray を返す
    '''
    extracted = extract_object(image, mask)
    clipped = clip_image_margin(extracted, mask)
    square_image = to_square(clipped)
    return square_image

def image_file_name_from_annotation(ann):
    '''アノテーションから画像ファイル名を返す
    '''
    return '%012d.jpg' % ann['image_id']

def load_image(ann, image_src_dir_path):
    '''アノテーションからアノテーション対象のオリジナル画像を返す
    '''
    # image は image_src_dir_path にアノテーション対象の画像があるのものとして読み込む (なければそのまま例外スロー)
    file_name = image_file_name_from_annotation(ann)
    src = Path(image_src_dir_path) / file_name
    image = np.array(Image.open(str(src)))
    return image

def make_datasets():
    '''写真画像のデータセットをつくる

    対象のみを切り出し白背景にして、32x32, 64x64, 128x128, 256x256 のサイズにリサイズした画像を作る。

    datasets/coco/annotations/instances_train2017.json のアノテーション情報と画像をもとに、以下のサイズごとのディレクリにリサイズして画像を置く

    - datasets/images/photo/32x32
    - datasets/images/photo/64x64
    - datasets/images/photo/128x128
    - datasets/images/photo/256x256

    各サイズごとに 447921 とか画像が作られるので時間がかかる (適当なところで break したほうがいいかも)
    '''

    # photo_image.py
    current_file_path = Path(__file__).resolve()

    # coco の情報
    coco_base_path = current_file_path.parent.joinpath('datasets/coco')
    coco_annotation_path = coco_base_path / 'annotations/instances_train2017.json'
    coco_image_path = coco_base_path / 'train2017'

    # 出力先
    image_datasets_path = current_file_path.parent.joinpath('datasets/images')
    image_datasets_path.mkdir(parents=True, exist_ok=True)

    # 写真画像の出力先
    photo_image_dataset_base_path = image_datasets_path / 'photo'
    photo_image_dataset_base_path.mkdir(parents=True, exist_ok=True)

    # 4サイズつくる
    sizes = [SIZE_32x32, SIZE_64x64, SIZE_128x128, SIZE_256x256]
    dests = ['32x32', '64x64', '128x128', '256x256']
    # ディレクトリ掘っておく
    for directory in dests:
        path = photo_image_dataset_base_path / directory
        path.mkdir(parents=True, exist_ok=True)

    size_with_dst_pairs = list(zip(sizes, dests))

    # coco のアノテーションと画像ファイルからリサイズしたデータセット画像作成
    coco = COCO(str(coco_annotation_path))
    ann_ids = coco.getAnnIds()
    for ann in coco.loadAnns(ann_ids):
        ann_id = ann['id']
        print(f'process {ann_id}')
        mask = coco.annToMask(ann)
        if not is_satisfied_object_size(mask):
            # サイズが小さければスキップ
            print('skipped.')
            continue
        image = load_image(ann, coco_image_path)
        square_image = extract_square_object_image(image, mask)
        object_image_file_name = f'{ann_id}.jpg'
        for size, dirname in size_with_dst_pairs:
            dest = photo_image_dataset_base_path.joinpath(dirname, object_image_file_name)
            resized = Image.fromarray(square_image).resize(size)
            resized.save(dest)
            print(f'save {dest}')

        print('done.')

if __name__ == '__main__':
    make_datasets()

3.実行環境

AWS で実行します。
前出の実装のリポジトリに colab (colaboratory) での実行例が用意されています (CycleGAN.ipynb)。馬とシマウマのデータセットで学習を試してみたところ半日で終わらず、200 epoch のうち数 epoch しか進んでいません。与えられたデータを全部使った学習を200回まわすようになっていますが、そのうちの数回しかまわせていませんでした。おそらく colab では事前に学習済みのデータ (pretrained model) を使って生成を試すのを想定していそうです。これよりデータ量の多い今回の再現も colab では手にあまります。なので AWS を利用します。

機械学習の用途なので NVIDIA Deep Learning AMI の AMI を利用してみます。Docker で実行することも考えましたが、本題以外の部分でハマりたくないため、EC2 でそのまま実行していきました。事前に作成して S3 にアップしておいたデータセットを持ってくるために awscli や、必要なライブラリ (libffi-dev などが必要になるかもしれません) を導入します。

以下のサイズで動作を見ていきます。

  • t2.micro
    • S3 のリソースにアクセスできるかなど雑多な準備・確認用
  • g4dn.xlarge (T4 x 1GPU)
    • GPU を使って実行できるかどうかの確認用 (colab でも同程度の GPU なので期待はしない)
  • g4dn.12xlarge (T4 x 4GPU)
    • 複数 GPU を使えるかどうかの確認用 (あわよくば現実的な実行時間で済むかかもくらいに期待)
  • p3.16xlarge (V100 x 8GPU)
    • 強い GPU で実行した場合にどれくらいの実行時間になりそうか確認する用

p3.16xlarge (8GPU を利用し、バッチサイズが 36 になるよう指定して実行、イテレーションは 5000) で動作確認して 1 epoch どれくらいかかるか見てみたところ、およそ120秒となりました。すなわち 200 epoch 完了するのに数時間かかります。ここに3回ファインチューニングを重ねるためさらに時間がかかります。今回の再現がこのコストに見合うかというと微妙なところです。ひとさまがすでに論文として仕上げているものを再現するためだけにこれほどのコストをかけてよいものか、また再現する価値があったとしてその先はどうするのか、少し考える必要があるように思います。

実際のところ、この数時間ぶん程度の実行コストは機械学習のタスクとしては現実的な範囲に見えます。しかし、ここからさらに実用的なものを目指したり発展させていくなら検証をかさねることになります。この先進むには当初の動機が軽すぎるためコストをかける説得力がありません。そういうわけで、ここで一旦再現を中断することにしました。低コストで効率的に実行する方法を探ったり、計画を立てるなどして説得力を増す必要があります。

実践編 まとめと感想

中途半端な状態で終わってしまいましたが、論文に書かれた内容を追って、そこに込められた工夫を知れたのはよかったです。理論を整理して実際に動かし検証をやりきるのにたくさんのリソース (人的にも計算資源的にも) がかかることを考えると、論文をまとめることのすごさを感じました。今度はもうすこし具体的な道筋を考えるか、小さくはじめられるこころみでリベンジしていきたいです。

記事を共有

最近人気な記事