努力と根性

背景

GANやVAEなどの生成モデルを使ってヌードグラビアを生成するということをやってます(最近ちょっとサボり気味、PUBG楽しい)。ここでは学習データをどうやって収集しているのかを紹介していきます。

主に使用するツール・言語 : python, bash, xargs, twphotos, keras

何をするのか

目標として以下の条件の画像を集めます。

  • 縦構図
  • 規定値以上のサイズ
  • カラー(グラビアにはモノクロ化、単色化されたものがあるがそれは除外する)
  • 頭から腰までは最低限写っていて正立に近い構図(頭が下、腰が上などの構図を除外する)
  • 「オッオッオッ」ってグッと来るもの

おおまかな流れ

前準備

  • Twitterでリストを作成する
  • Twitterリストに欲しい画像をたくさんアップロードしているアカウントを収集する

プログラム

  • APIを使ってTwitterリストのメンバー名を収集する
  • twphotosを使って画像をダウンロードする
    • その後ダウンロード失敗ファイルを取り除く
  • 画像の基礎情報(サイズ、HSV統計量など)で欲しいファイルをフィルタする
  • Kerasで作った画像分類で目的の構図かどうかを荒く仕分ける
    • この画像分類は画像を追加するたびに学習する
  • 最後に手動で目的の画像を仕分ける

前準備、Twitterリストの作成とリストユーザーの取得

Twitterのリスト機能を使って画像をたくさん投稿しているアカウントを集めます。自分はヌード画像をたくさん投稿しているアカウントを集めました。なんか海外アカウントでそういうひたすらヌード画像を投稿する人たち(bot?)がたくさんいてお互いにRTし合っているので結構簡単に量が確保できました。一節には「ヌード画像アカウントをフォローするようなアカウントを収集している」と言われていますが気にしない。

観光関係のアカウントや建物紹介アカウントなんてのもあるし動物写真アカウントなんてのもあるので応用したリストは作りやすいと思います。(例えば動物写真を集めるなら)個人でよく動物写真を投稿する人よりも動物写真専門のアカウントを集めた方が良いです。前者はあくまで個人の趣味なのでメシやらなんやら動物でない写真も混ざってしまいます。後者でもたまに(管理人の趣味や告知などで)目的ではない写真が混ざりますがそれは後段で排除します。

前準備、APIを使ったTwitterリストユーザーの取得

次段で使うツールがグループ一括ダウンロードに対応していないためTwitterリストに入っているユーザー名を取得しておきます。

pythonでtwitterパッケージを使って以下のような感じ(若干ゃダサ)。

import twitter as tw                                                                                                                                                                                        
import os, sys 
import configparser as cp

# support tool for twitter-photos
config = cp.ConfigParser()
config.read(os.path.expanduser('~/.my_twitter'))

c_key = config.get('credentials', 'consumer_key')
c_sec = config.get('credentials', 'consumer_secret')
a_key = config.get('credentials', 'access_token_key')
a_sec = config.get('credentials', 'access_token_secret')

t = tw.Twitter(auth=tw.OAuth(a_key, a_sec, c_key, c_sec))
lists = t.lists.list()

if len(sys.argv) < 2:
    for l in lists:
        print(l)
        print(l['name'])
else:
    next_cursor=None
    for l in lists:
        if l['name'] == sys.argv[1]:
            while next_cursor != 0:
                args = {'list_id': l['id'], 'count': 5000}
                if next_cursor: args['cursor'] = next_cursor
                users = t.lists.members(**args)
                next_cursor = users['next_cursor']
                for u in users['users']:
                    print(u['screen_name'])

twphotosを使って画像をダウンロードする

twphotosというツールを使ってダウンロードします。python製で pip install twitter-photos でインストールできます。先に作ったTwitterリストに入っているユーザー全員分の画像を取得したく、また並列で行いたいため xargs を使用します。諸々のエラーによりJPEGでないもの(HTTPエラーのメッセージとか)が *.jpg として出力されてしまうことがあるので一括ダウンロードのあとにそれらを取り除きます。

#!/bin/bash                                                                                                                                                                                                 

bash update_user.sh # 上のpythonスクリプトを読んだりしてユーザー名一覧 users.txt を更新します

pushd pics

# Twitterのサーバに土下座しながら並列で画像をダウンロードします。
cat users.txt | xargs -P 4 -I {}  twphotos -u {} -r -i

# ダウンロードした *.jpg ファイルについて file コマンドが JPEG と判定しなかったものを削除します
find . -name "*.jpg" | \ 
    xargs -I {} file {} | \ 
    grep --line-buffered -v JPEG | \ 
    sed -u 's/^\(.*\.jpg\).*$/\1/' | \ 
    xargs rm
popd

画像の基礎情報(サイズ、HSVの統計量など)で欲しいファイルをフィルタする

ダウンロードしてきた画像ファイルは確かに求めるジャンルが多いのですが色彩についてカラーを求めているのにモノクロが入っていたり、アスペクト比が求めるものでなかったりします。まずは基本的な画像の情報でファイルを選別します。

選別には自作の filterimages というpythonスクリプトを使用しています。サイズやアスペクト比、HSVのS平均値上限下限などを設定してファイルを判定します。画像の読み込みや色変換には pillow、統計には numpy を使用しています。

実際には以下のスクリプトを走らせています。最後に imagemagick を使用して目的のサイズへのスケール変換も行っています。

#!/bin/bash                                                                                                                                                                                                 

export PYTHONUNBUFFERED=1

# 幅W128以上、高さH192以上、アスペクト比0.5 < (H/W) < 0.7、彩度の平均45位上、分散15位上
# 縦長でモノクロとは言えない程度に色が乗っている画像を選別してファイル名をフィルタにダンプ
find pics -type f -name "*.jpg" | filterimages --width-min=128 --height-min=192 --aspect-max=0.7 --aspect-min=0.5 --sat-stddev-min=15 --sat-avg-min=45 | tee vertical_color_over_128x192.txt

mkdir -p vertical_color_over_128x192
rm -rf vertical_color_over_128x192/*
cat vertical_color_over_128x192.txt | xargs -I {} cp {} vertical_color_over_128x192/

# ダウンロード画像全体から上の条件に合う画像をコピーして mogrify(imagemagickのコマンドのひとつ) でスケール変換
pushd vertical_color_over_128x192
find . -type f -name "*.jpg" | xargs -I {} -P 12 mogrify -resize 128x192^ -gravity center -extent 128x192 {}
popd

Kerasで作った画像分類で目的の構図かどうかを荒く仕分ける

ここまででサイズや色彩についてはある程度の目的の画像群が抽出できました。しかし肝心なのは写っているものです。真面目にやるなら1枚1枚手作業でタグ付けですがここでは少しサボります。

  1. 画像群から数百枚取り出し条件に合う画像と合わない画像を手作業で仕分ける
    • 条件は先に上げたとおり「最低限、頭から腰は写っている」「頭が上、腰が下」「セクシーな感じ」という人間が判断しないといけないもの
  2. Kerasで分類器を学習させる
  3. 残りの画像から1000枚を取り出して分類する
  4. 結果のうち条件を満たしていない不正解を手動で仕分け直す
  5. 仕分けした結果を学習データに追加する
  6. 2.に戻る

このプロセスを繰り返すことで「完全にバラバラなファイル群を仕分ける作業」から「ある程度自動で仕分けされたファイル群を仕分ける作業」に移行することができます。

重要な原則 : 「50:50に混ざったものを仕分ける」よりも「99:1で混ざったものから1の方を取り除く」方がはるかに簡単

Kerasのモデルコードは以下になります。小学生並みのモデル。

from keras.models import Sequential                                                                                                                                                                         
from keras.layers import Conv2D, Dense, Activation, Flatten, Dropout

def create_model():
    model = Sequential()

    conv_param = dict(activation='relu', padding='same', use_bias=True)
    dense_param = dict(activation='relu', use_bias=True)
    final_param = dict(activation='sigmoid', use_bias=True)

    # 128
    model.add(Conv2D(32, (3,3), input_shape=(128,128,3), **conv_param))
    model.add(Conv2D(64, (3,3), **conv_param))
    model.add(Conv2D(64, (3,3), **conv_param))
    # 64
    model.add(Conv2D(128, (3,3), strides=(2,2), **conv_param))
    model.add(Conv2D(128, (3,3), **conv_param))
    model.add(Conv2D(128, (3,3), **conv_param))
    # 32
    model.add(Conv2D(256, (3,3), strides=(2,2), **conv_param))
    model.add(Conv2D(256, (3,3), **conv_param))
    model.add(Conv2D(256, (3,3), **conv_param))
    # 16
    model.add(Conv2D(512, (3,3), strides=(2,2), **conv_param))
    model.add(Conv2D(512, (3,3), **conv_param))
    model.add(Conv2D(512, (3,3), **conv_param))
    # 8 
    model.add(Conv2D(1024, (3,3), strides=(2,2), **conv_param))
    model.add(Conv2D(1024, (3,3), **conv_param))
    model.add(Conv2D(1024, (3,3), **conv_param))
    # 4 
    model.add(Conv2D(2048, (3,3), strides=(2,2), **conv_param))
    model.add(Conv2D(2048, (3,3), **conv_param))
    model.add(Conv2D(2048, (3,3), **conv_param))
    # dence
    model.add(Flatten())
    model.add(Dense(2048, **dense_param))
    model.add(Dropout(0.5))
    model.add(Dense(1024, **dense_param))
    model.add(Dense(   2, **final_param))

    return model

Kerasの学習コードは以下になります。簡潔。

IMAGES_DIR='/mnt/data/machine_learning/sexy_photos/classify/train'                                                                                                                                          
STEPS_PER_EPOCH_MAX=20000

from pathlib import Path

files_num = sum(1 for i in Path(IMAGES_DIR).glob('**/*.jpg') )
print('train {} files'.format(files_num))

from keras.preprocessing.image import ImageDataGenerator

data_gen_args = dict(
    horizontal_flip=True, rescale=1/255)

train_data_generator = ImageDataGenerator(**data_gen_args)
train_data = train_data_generator.flow_from_directory(
    IMAGES_DIR, target_size=(128,128), class_mode='binary')

import classifier

model = classifier.create_model()

from pathlib import Path

if Path('classifier.h5').exists():
    model.load_weights('classifier.h5')

from keras.optimizers import Adam

model.compile(
    optimizer=Adam(lr=1e-5), 
    loss='sparse_categorical_crossentropy', metrics=['accuracy'])

model.fit_generator(train_data, steps_per_epoch=min(STEPS_PER_EPOCH_MAX, files_num), epochs=5)
model.save_weights('classifier.h5')

分類コードです。簡潔。

IMAGES_DIR='/mnt/data/machine_learning/sexy_photos/classify/test'                                                                                                                                           
BATCH_SIZE=64

from pathlib import Path
import sys 

files_num = sum(1 for i in Path(IMAGES_DIR).glob('**/*.jpg') )
print('test {} files'.format(files_num), file=sys.stderr)

from keras.preprocessing.image import ImageDataGenerator

data_gen_args = dict(rescale=1/255)

image_data_generator = ImageDataGenerator(**data_gen_args)
image_data = image_data_generator.flow_from_directory(
    IMAGES_DIR, target_size=(128,128), class_mode=None,
    shuffle=False, batch_size=BATCH_SIZE)

import classifier

model = classifier.create_model()

model.load_weights('classifier.h5')

batches = 0 
tested = 0 

import numpy as np

for x in image_data:
    out = model.predict(x)

    for n, v in zip(image_data.filenames[tested:tested+len(out)], out):
        print('srcdir/{} dstdir/{}'.format(n,np.argmax(v)))

    batches += 1
    tested += len(out)

    print('{} images tested'.format(tested), file=sys.stderr)

    if tested >= files_num:
        break

この程度のコード量で「あー、ちょっとした分類器つくりてー」ってことが出来てしまうのだから幸せな時代です。

あとは仕分けた画像を使って生成モデルの学習をするだけです。

まとめ

以上が自分が生成モデルのデータセットを作るのに採用している手法です。今のところ原画像が43万枚くらい。条件抽出で8千枚くらい仕分けてまだまだ作業中。ダウンロードしたファイルをそのまま目視で分けるよりは随分効率がよいです。

個人の研究でのデータセットづくりは非常にダルいのですが独自のデータセットは趣味でドヤァするには価値のある資産になります。頑張りたいと思います。