kivantium活動日記

プログラムを使っていろいろやります

タイムラインから二次元イラストだけを表示するWebアプリの作成

ここまでの成果を使って、タイムラインから二次元イラストだけを表示するTwitterクライアントっぽいWebアプリを作成します。

スクリーンショット

出来上がったものがこちらになります。

f:id:kivantium:20200423234030p:plain:w600
スクリーンショット

以下、コードと今後の課題を述べます。

コード

コード全文はGitHubを見てください。 github.com

簡単のため、ログイン済みユーザーがアクセスするたびにタイムラインから最新のツイート200件を読み込んで、二次元画像判別器が二次元イラストだと判定した画像つきツイートを表示することにしました。200件以上のツイートを同時に読み込むのはTwitter APIの制限上難しかったです。 リツイートに関しては、リツイートした人の情報ではなくリツイート元の情報を表示することにします。英語で280文字までツイートできるようにする最近の仕様変更に対応するために少し面倒な処理を行っています。(参照: Extended Tweets — tweepy 3.8.0 documentation

前回作成したRandom Forestによる判定器や、ONNX版Illustration2Vecをhello/以下に置いています。

hello/views.py

import os
import re
import urllib.request
from urllib.parse import urlparse
from PIL import Image
from joblib import dump, load
import tweepy

from django.shortcuts import render
from social_django.models import UserSocialAuth
from django.conf import settings
import more_itertools

import sys
sys.path.append(os.path.dirname(__file__))
import i2v

# ONNX版Illustration2Vec
illust2vec = i2v.make_i2v_with_onnx(os.path.join(os.path.dirname(__file__), "illust2vec_ver200.onnx"))

# 事前に作成しておいた二次元画像判別器
clf = load(os.path.join(os.path.dirname(__file__), "clf.joblib"))

def index(request):
    if request.user.is_authenticated:  # Twitterでログインしている場合
        # ユーザー情報の取得
        user = UserSocialAuth.objects.get(user_id=request.user.id)
        consumer_key = settings.SOCIAL_AUTH_TWITTER_KEY
        consumer_secret = settings.SOCIAL_AUTH_TWITTER_SECRET
        access_token = user.extra_data['access_token']['oauth_token']
        access_secret = user.extra_data['access_token']['oauth_token_secret']
        auth = tweepy.OAuthHandler(consumer_key, consumer_secret)
        auth.set_access_token(access_token, access_secret)
        api = tweepy.API(auth)
        # 全文を取得するためにextendedを指定する
        timeline = api.home_timeline(count=200, tweet_mode = 'extended')

        tweet_illust = []
        for tweet in timeline:
            if 'media' in tweet.entities:
                media  = tweet.extended_entities['media'][0]
                media_url = media['media_url']
                filename = os.path.basename(urlparse(media_url).path)
                filename = os.path.join(os.path.dirname(__file__), 'images', filename)
                urllib.request.urlretrieve(media_url, filename)
                img = Image.open(filename)
                feature = illust2vec.extract_feature([img])
                prob = clf.predict_proba(feature)[0]
                if prob[1] > 0.4:  # 二次元イラストの可能性が高い
                    if hasattr(tweet, "retweeted_status"): 
                        profile_image_url = tweet.retweeted_status.author.profile_image_url_https
                        author = {'name': tweet.retweeted_status.author.name,
                                  'screen_name': tweet.retweeted_status.author.screen_name}
                        id_str = tweet.retweeted_status.id_str
                    else:
                        profile_image_url = tweet.author.profile_image_url_https
                        author = {'name': tweet.author.name,
                                  'screen_name': tweet.author.screen_name}
                        id_str = tweet.id_str
                    # リツイート元のツイート全文の取得
                    try:
                        text = tweet.retweeted_status.full_text
                    except AttributeError:
                        text = tweet.full_text
                    # 画像URLを削除するために文末のURLを削除する
                    text = re.sub(r"https?://[\w/:%#\$&\?\(\)~\.=\+\-]+$", '', text).rstrip()
                    tweet_illust.append({'id_str': id_str, 
                                         'profile_image_url': profile_image_url,
                                         'author': author,
                                         'text': text,
                                         'image_url': media_url})
        # 表示の都合上4つずつのグループに分ける
        tweet_illust_chunked = list(more_itertools.chunked(tweet_illust, 4))
        return render(request,'hello/index.html', {'user': user, 'timeline_chunked': tweet_illust_chunked})
    else:
        return render(request,'hello/index.html')

これを表示するためのHTMLを示します。Bulmaで画像の中央を丸く切り取って並べる - kivantium活動日記の応用です。Bulmaのカード機能を使っています。 bulma.io

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>にじさーち</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
    <style>
img { object-fit: cover; }
.bm--card-equal-height {
   display: flex;
   flex-direction: column;
   height: 100%;
}
.bm--card-equal-height .card-footer {
   margin-top: auto;
} </style>
  </head>
  <body>
  <nav class="navbar is-primary">
    <div class="navbar-brand navbar-item">
      <h1 class="title has-text-light">にじさーち</h1>
    </div>
    {% if request.user.is_authenticated %}
    <div class="navbar-end">
      <div class="navbar-item">
      <a class="button is-light" href="/logout">ログアウト</a>
      </div>
    {% endif %}
    </div>
  </nav>
  <section class="section">
    <div class="container">
      {% if request.user.is_authenticated %}
      {% for tweets in timeline_chunked %}
      <div class="columns is-mobile">
        {% for tweet in tweets %}
        <div class="column is-3">
          <div class="card bm--card-equal-height">
            <div class="card-content">
              <div class="media">
                <div class="media-left">
                  <figure class="image is-48x48">
                    <img src="{{ tweet.profile_image_url }}" alt="Profile image">
                  </figure>
                </div>
                <div class="media-content">
                  <p class="title is-4">{{ tweet.author.name }}</p>
                  <p class="subtitle is-6">@{{ tweet.author.screen_name }}</p>
                </div>
              </div>
              <div class="card-image">
                <figure class="image is-square">
                  <a href="https://twitter.com/i/web/status/{{ tweet.id_str }}" target="_blank" rel="noopener noreferrer">
                    <img src="{{ tweet.image_url }}" alt="main image">
                  </a>
                </figure>
              </div>
              <div class="content">{{ tweet.text }}</div>
            </div>
          </div>
        </div>
        {% endfor %}
      </div>
      {% endfor %}
      {% else %}
      <p>あなたはログインしていません</p>
      <button type="button" onclick="location.href='{% url 'social:begin' 'twitter' %}'">Twitterでログイン</button>
      {% endif %}
    </div>
  </section>
  </body>
</html>

今後の課題

Illustration2Vecのモデルが重い

今回作成したアプリをサーバーにデプロイしようと思ったのですが、Illustration2Vecのモデルがサーバーのメモリサイズよりも大きかったためデプロイすることができませんでした。また、今後複数のユーザーによる使用をサポートしようとするとアクセスが来るたびにIllustration2Vecを実行していてはとても追いつかないのでモデルを軽量化することが必要になりそうです。

画像データベースとしての利用

Twitter APIのRate Limitが厳しいため、タイムラインから一度に収集できるツイートは200件くらいしかありません。これでは大量の画像を閲覧する目的に向きません。そのため、status/filterで常に画像を収集しておき画像データベースとして利用することが考えられます。しかし、類似のサービスが(利用規約に則っているにも関わらず)以前大炎上したことがあるっぽいので、Twitterクライアントとして一般に認められる以上の機能を提供するとなると面倒くさそうです。 nlab.itmedia.co.jp

自動タグ付け

Illustration2Vecでもタグ付けを行うことができますが、つけることができるタグの種類は有限です。新しく増える作品やキャラに対応するために何らかの方法で類似画像のハッシュタグからタグを推定して自動タグ付けができるとよさそうです。(これも絵師界隈の自主ルールで難癖つけられて面倒なことになりそうですが……)

Display requirementsへの適合

利用規約に則ってTwitterのコンテンツを表示する際の条件としてDisplay Requirementsというものがあります。 developer.twitter.com

ツイート本文を全文表示しないといけないとか、Twitterのロゴを右上に表示しないといけないなどといったユーザーの利便性を損なう規定なのですが、規定なので従う必要があります。 Google画像検索のようにサムネイルだけ表示してあとはツイートへのリンクにする方式にすることも含めて検討していきたいです。

二次元画像判別器に対するActive Learning導入の検討

前回の記事では、Twitter上の画像から二次元画像を選ぼうとすると二次元とも三次元とも言い難い画像が入ってくる問題があることを見ました。今回は、Active Learningという方法を使って境界領域の画像をうまく扱う方法を適用したいと思います。

Active Learningについて

Active Learningという言葉は教育業界と機械学習業界の両方で使われているので混乱がありますが、ここでは機械学習でのActive learningを指します。通常の機械学習の問題設定では学習データは既に与えられたものとして扱うことが多いですが、Active Leaningではどのデータを学習するかを選ぶことができるという設定のもとで学習を行います。これにより、少ないデータ数で学習が行えるようになることが期待できます。

f:id:kivantium:20200418181517p:plain:w600
Active Learningでは、境界に近いデータを能動的に選ぶことで効率的に学習を行うことを目指す。
ICML 2019のActive Learningチュートリアルのスライドより。)

以下、Active Learning Literature Surveyの内容に沿って話を進めます。

Active Learningの主なシナリオには3つあります。

  • Membership Query Synthesis: 学習器が入力空間中の任意のラベルなしインスタンスについてラベル付けを要求できる(新しく生成したインスタンスでも良い)
  • Stream-Based Selective Sampling: 1つずつ流れてくるデータそれぞれについてラベルを要求するか破棄するかを決める
  • Pool-Based Sampling: ラベル付きデータとラベルなしデータが与えられ、ラベルなしデータの中からどのデータにラベル付けを要求するか決める

どのデータに対してラベルを要求するかを決定する基準として最もよく使われているのがUncertainty Samplingという方式で、主なものが3種類あります。

  • least confident: 一番確信度が低いものを選ぶ。数式で書くと、 1 − P(\hat{y}|x) が最大のものを選ぶ(\hat{y} = \mathrm{arg max}_y P(y|x))。
  • margin sampling: 一番可能性が高いクラスと二番目に可能性が高いクラスの分類確率の差が一番小さいものを選ぶ。数式で書くと、 P(\hat{y}_1|x) − P(\hat{y}_2|x) が最小のものを選ぶ。
  • entropy: エントロピーが最大のものを選ぶ。数式で書くと、 -\sum_{i} P(y_i|x) \log{P(y_i|x)}が最大のものを選ぶ。

二次元画像判別に対する応用

今回は、ラベル付けを行った画像とラベルがついていない画像が与えられているのでPool-Based Samplingのシナリオになります。とりあえず一番簡単そうなmargin samplingを使って、昨日ラベル付けをサボったデータに対してActive Learningをやってみようと思ったのですが、1個ずつラベル付けするのは面倒なので、分類確率の差が0.3より小さいデータがどんな感じのデータになるのかを見てみることにします。

import os
import shutil

import numpy as np
from PIL import Image
import more_itertools
from sklearn.svm import SVC
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from tqdm import tqdm

import i2v

illust2vec = i2v.make_i2v_with_onnx("illust2vec_ver200.onnx")

# 学習データの準備
X = []
y = []
batch_size = 4

negative_path = '0'
negative_list = os.listdir(negative_path)
for batch in tqdm(list(more_itertools.chunked(negative_list, batch_size))):
    img_list = [Image.open(os.path.join(negative_path, f)) for f in batch]
    features = illust2vec.extract_feature(img_list)
    X.extend(features)
    y.extend([0] * len(batch))

positive_path = '1'
positive_list = os.listdir(positive_path)
for batch in tqdm(list(more_itertools.chunked(positive_list, batch_size))):
    img_list = [Image.open(os.path.join(positive_path, f)) for f in batch]
    features = illust2vec.extract_feature(img_list)
    X.extend(features)
    y.extend([1] * len(batch))

# Random Forestの学習
clf = RandomForestClassifier(max_depth=2, random_state=0)
clf.fit(X, y)

# Unlabeled データをフォルダ分けする
pool_path = 'unlabeled'
pool_list = os.listdir(pool_path)
for filename in pool_list:
    filename = os.path.join(pool_path, filename)
    img = Image.open(filename)
    feature = illust2vec.extract_feature([img])
    prob = clf.predict_proba(feature)[0]
    # 確率値の差が0.3以下ならラベル付けを要求する
    if np.abs(prob[0]-prob[1]) < 0.3:
        shutil.move(filename, 'uncertain')
    elif prob[0] > prob[1]:
        shutil.move(filename, 'negative')
    else:
        shutil.move(filename, 'positive')

Unlabeledデータ2021枚のうち、uncertainに分類されたものが193枚、negativeに分類されたものが1783枚、positiveに分類されたものが52枚でした。

f:id:kivantium:20200418191154p:plain:w600
紛らわしいと判定された画像

uncertainに分類された画像をさらに詳しく見てみました。

前回の記事で述べた紛らわしい種類の画像がきちんとuncertainに分類されており、Random Forestによる分類確率が紛らわしさをきちんと捉えていることが確認できました。

positiveに分類された画像はアニメのスクリーンショット1枚を除いて全てイラストでした。

f:id:kivantium:20200418190731p:plain:w600
二次元イラストだと判定された画像

negativeに分類された画像のうちイラストは34枚でした。これらの画像は、コントラストが薄めである・人間がたくさん書かれているなどの理由から漫画と間違えられた可能性が高いと思っています。(今回のラベリングではコマ割りがあるまたは白黒の画像は全て二次元イラストではないとしています)

f:id:kivantium:20200418190429p:plain:w600
二次元イラストではないと間違えて判定されたイラスト

以上の結果から、margin samplingは二次元画像分類の境界ケースをきちんと集めることができそうだという感触を得ました。これを学習データに加えたら精度が上がったという実験結果を出せればよかったのですが、ランダムサンプリングでも95%くらいの精度が出ていたのでActive Learningで有意差を出すことが難しそうでした。Active Learningをするというよりは、棄却オプションをつけて不確かな画像は人手で分類するようにするのが良さそうです。

次回はこの結果を使って二次元画像だけのタイムラインを表示するアプリを作ろうと思います。

二次元画像判別器の作成に関する基礎検討

Abstract

Twitterに流れる大量の画像の中から二次元画像を集めることは私のQoL向上の上で非常に重要な問題である。 本研究では、著者のタイムラインに実際に流れてきた画像を分析し、二次元画像分類という問題の定義が難しいことを示した。 また、独自に定義した問題設定に基づいてデータセットを作成し、Illustration2VecとRandom Forestを組み合わせることで accuracy 0.98 を達成するモデルを作成した。 これにより今後の二次元画像収集に関する研究の方針が明らかになった。

Introduction

Twitterのタイムラインには非常に多くの画像が流れており、その中から自分の好みの二次元画像を収集することは非常に困難である。神絵師アカウントが投稿する画像はイラストばかりとは限らず、むしろ焼肉等の飯テロ画像やソシャゲのガチャ結果などの非イラスト画像の割合のほうが高い場合が多い。そのため、神絵師を集めたリストを眺めても目的の二次元画像が得られる割合が少なく、快適な画像収集ライフの妨げとなっている。

二次元画像の自動判定の問題は、オタク機械学習界隈ではよく知られている。Ideyoshiらは画素値ヒストグラムを特徴量とした機械学習モデルを用いて二次元画像判別器を作成した [1]。また、TachibanaはIllustration2Vecを元にしたニューラルネットワークを用いて二次元画像判別器を作成した [2]。

これらの研究はいずれも二次元画像として人が収集したTumblrデータ、三次元画像としてImagenetのデータを用いていた。この画像データセットにおいては二次元・三次元の区別が明確であるが、実際にタイムラインに流れてくる画像は二次元・三次元の区別が明確ではないものが数多く存在する。

本研究では、実際にタイムラインに流れてくる画像を収集し、二次元画像判別問題を正しく定義することが難しいことを示した。また、困難を回避できる独自のデータ収集基準を設定した上でデータセットの作成を行い、Illustration2VecとRandom Forestを組み合わせたモデルを用いてaccuracy 0.98を達成した。

  • [1] Mori Ideyoshi, Falsita Fawcett, Fall Through, Makoto Sawatar. 機械学習による二次元/三次元画像判別. SIG2D'13, pp. 1-6, 2013.
  • [2] Hazuki Tachibana. Illustration2Vec に基づく高精度な二次元画像判別器の作成. SIG2D'15, pp. 9-12, 2015.

Method

Twitter画像の収集

以前の記事で説明したstatuses/filterを用いて、followにkivantiumのフォロワー全員を指定して画像の収集を行った。収集期間は4月16日深夜〜4月18日昼頃である。

kivantium.hateblo.jp

二次元画像判別器の作成

以前の記事で作成した、ONNX経由でIllustration2Vecを作成するライブラリを用いて画像から特徴量を抽出し、Random Forestで分類するモデルを作成した。

ネガティブデータを0、ポジティブデータを1というディレクトリに入れて、次のようなPythonプログラムを実行した。

import os
from pprint import pprint

import numpy as np
from PIL import Image
import more_itertools
from sklearn.svm import SVC
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from tqdm import tqdm

import i2v

illust2vec = i2v.make_i2v_with_onnx("illust2vec_ver200.onnx")

X = []
y = []
batch_size = 4

negative_path = '0'
negative_list = os.listdir(negative_path)
for batch in tqdm(list(more_itertools.chunked(negative_list, batch_size))):
    img_list = [Image.open(os.path.join(negative_path, f)) for f in batch]
    features = illust2vec.extract_feature(img_list)
    X.extend(features)
    y.extend([0] * len(batch))

positive_path = '1'
positive_list = os.listdir(positive_path)
for batch in tqdm(list(more_itertools.chunked(positive_list, batch_size))):
    img_list = [Image.open(os.path.join(positive_path, f)) for f in batch]
    features = illust2vec.extract_feature(img_list)
    X.extend(features)
    y.extend([1] * len(batch))

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

clf = RandomForestClassifier(max_depth=2, random_state=0)
clf.fit(X_train, y_train)
print(clf.score(X_test, y_test))

Result

Twitter画像の収集

収集された画像のうち、明らかに二次元画像と判定されるものの例を示す。

かわいい。

このように、明らかに二次元画像と判定できる画像も存在したが、一方で二次元画像と呼ぶべきなのかよく分からないものが数多く存在した。一例を上げると

  • 写真とイラストを並べた画像
LƏGS from r/Animemes

また、既存の漫画のコマやアニメ・ソシャゲのスクリーンショットなどは二次元画像であるが、特に収集したい対象ではない。

以上を踏まえて、以下のデータセット作成基準を制定した。

  • かわいい二次元画像だと私が判定したカラーの一枚絵のイラストをポジティブデータとする
  • 二次元画像だが収集したくないと判定した二次元画像と、コマ割りがあるか白黒の二次元画像は保留データとする
  • それ以外の画像をネガティブデータとする。

このような非科学的な根拠に基づいてデータセットを作成しているので以下の評価は全てデタラメである。

保留データをネガティブデータとして利用すると、ポジティブ・ネガティブの差があいまいになって学習が進みにくくなりそうだったので、とりあえず明確に区別できそうなポジティブとネガティブのデータだけを用いて評価することにした。

二次元画像判別器の評価

収集した6000枚くらいの画像のうち、以上の基準に基づいて700枚くらいをポジティブデータ、2000枚くらいをネガティブデータ、1300枚くらいを保留データとした。残りの2000枚くらいの画像はラベリングに飽きたので放置してある。

そのうち200枚のポジティブデータと350枚のネガティブデータを用いて上記のプログラムを動かして評価したところ、accuracy 0.98という結果を得た。せっかくラベリングしたのに一部のデータしか使わなかったのは思ったより特徴量抽出に時間が掛かって寝る前に学習が終わらなさそうだったからである。

Discussion

抽出した特徴量を何もチューニングしてないRandom Forestに入れるだけでaccuracy 0.98が出ることから、二次元画像判別問題は難しい入力ケースを除けばだいぶ簡単な問題であったことが分かる。(自明なことを言っているだけだな)

上記で学習したランダムフォレストを試しに保留データに適用してみたところ、アニメやソシャゲのスクリーンショットは二次元画像として判定されてしまった。学習データ中にソシャゲのスクリーンショットは必要ではないという情報がない以上、これは当然の結果である。現実のデータでは二次元画像と三次元画像が明確に分かれていないことが判明した以上、必要となるのは二次元画像のうち収集したいタイプの画像を区別する何らかの方法を考えることである。人間が見ればアニメ・ソシャゲのスクリーンショットと創作イラストの区別は明確であることがほとんどであるため、機械学習さんにも区別できる可能性はある。

ポジティブとネガティブの境界をどうするかというのは当然機械学習で重要な問題であるため、先行研究が存在する。そのうち特に面白そうなのはアクティブ・ラーニングと呼ばれる分野である。アクティブラーニングでは確信が持てない訓練データを選び出して人間にラベリングさせることで少ないラベル数で効率よく学習させることを目指しているらしいので、境界が問題になる現状を解決する手がかりになる気がする。今週末のテーマとして勉強してみるのがいいだろう。

Bulmaで画像の中央を丸く切り取って並べる

「Bootstrapを使って作ったWebサイトはいかにもBootstrapを使って作った感が出てしまうからやめたい」とWebのプロに相談したらBulmaを使うといいと言われたので、最近はBulmaを使っています。 bulma.io

画像の中央を丸く切り取って並べるデザインをする必要があったので、記録しておきます。

画像の中央を丸く切り取って並べるとは

Twitterのアイコン画像には正方形以外の画像を設定することもできます。その場合、画像の中心から画像の縦幅と横幅の小さい方を直径とする円で切り抜いた部分がアイコンとして表示されます。 これをCSSで実現するにはどうすればいいかという話です。

Bulmaのコラム機能を使った例

まず、画像の中央をトリミングするCSSobject-fitプロパティを使うと実現することができます。

切り抜くときにBulmaでfigureタグにis-squareを、imgタグにis-roundedを指定すると丸く切り抜くことができます。

横に並べるためにコラム機能を使いました。

スマホの画面でも横並びにするためにis-mobileを指定するのがポイントでした。

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>example</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
    <style>img { object-fit: cover; }</style>
  </head>
  <body>
    <section class="section">
      <div class="container">
        <h2 class="title">images</h2>
        <div class="columns is-mobile">
          <div class="column is-4">
            <figure class="image is-square">
              <img class="is-rounded" alt="スーパー日本人のイラスト" src="image1.png">
            </figure>
          </div>
          <div class="column is-4">
            <figure class="image is-square">
              <img class="is-rounded" alt="AIに支配される人達のイラスト" src="image2.png">
            </figure>
          </div>
          <div class="column is-4">
            <figure class="image is-square">
              <img class="is-rounded" alt="研究が上手く行かない人のイラスト" src="image3.png">
            </figure>
          </div>
        </div>
      </div>
    </section>
  </body>
</html>

f:id:kivantium:20200417095555p:plain
スクリーンショット

しかし、この方法では1列ごとに並べる数を決め打ちすることになるので、デスクトップでは6列だがスマートフォンでは4列にするようなレイアウトが実現できません。 そういう機能はCSS Gridを使って実現できるらしいので今度やってみます。 www.webcreatorbox.com

(追記) 12の約数という制限はあるものの、is-multilineを指定すれば列数指定ができました。

stackoverflow.com

Bulmaのタイル機能を使った例

より複雑に並べたい場合はタイル機能を使います。

例を示します。

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>example</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
    <style>img { object-fit: cover; }</style>
  </head>
  <body>
    <section class="section">
      <div class="container">
        <h2 class="title">images</h2>
        <div class="tile is-ancestor">
          <div class="tile is-parent">
            <article class="tile is-4 is-child box">
              <figure class="image is-square">
                <img class="is-rounded" alt="スーパー日本人のイラスト" src="image1.png">
              </figure>
              <p class="subtitle">image 1</p>
            </article>
            <article class="tile is-4 is-child box">
              <figure class="image is-square">
                <img class="is-rounded" alt="AIに支配される人達のイラスト" src="image2.png">
              </figure>
              <p class="subtitle">image 2</p>
            </article>
            <article class="tile is-4 is-child box">
              <figure class="image is-square">
                <img class="is-rounded" alt="研究が上手く行かない人のイラスト" src="image3.png">
              </figure>
              <p class="subtitle">image 3</p>
            </article>
          </div>
        </div>
      </div>
    </section>
  </body>
</html>

f:id:kivantium:20200416232045p:plain
スクリーンショット

問題点としては、この方法を使うと画面幅が一定値より小さくなったときに1段組になってしまうことが挙げられます。

f:id:kivantium:20200416232259p:plain:w300
画面幅が狭いときのスクリーンショット

画面幅が小さいときでも強制的に並べることはできないのか?というissueはありましたが、回答がありませんでした。 github.com

解決したら更新します。

TwitterのStreaming APIについて

2018年8月にTwitterのUser Streams APIが廃止されてしまい、タイムラインのツイートをリアルタイムで取得することができなくなってしまいました。 forest.watch.impress.co.jp

それに伴い、User Streams APIを使っていたリプライによるアイコン変更スクリプトなどが動かなくなっていました。

昨日から開発を始めた画像収集スクリプトを作るためにTweepyのドキュメントを眺めていたら、Streaming APIというものが現在も存在していることに気がついたのでこれを紹介します。

(2015年からTweepyに実装されていたのに今日まで気づかなかった……。ドキュメントはちゃんと読むべき。)

statuses/filterの仕様

2020年4月15日現在、Twitter APIの紹介ページでタイトルにrealtimeとつくものにはFilter realtime TweetsSample realtime Tweetsの2つがあります。このうち、無料ユーザーが使えるのはstatuses/filterだけっぽいです。

developer.twitter.com

このAPIを使うと特定の条件を満たすツイートをリアルタイムで取得できます。パラメータは5つあります。

  • follow: ユーザーIDのリスト(上限5000個)
  • track: 検索キーワード(上限400個)
  • locations: ツイートされた場所(上限25個)
  • delimited: メッセージを長さで区切るかどうか
  • stall_warnings: stall warningsの有無

follow, track, locationsから複数を指定した場合、ORでつないだものとみなされます。

followで指定したユーザーについて、以下を満たすツイートが取得できます。(カッコ内は原文)

  • 指定したユーザーによるツイート (Tweets created by the user.)
  • 指定したユーザーがリツイートしたツイート (Tweets which are retweeted by the user.)
  • 指定したユーザーの任意のツイートに対するリプライ (Replies to any Tweet created by the user.)
  • 指定したユーザーの任意のツイートのリツイート (Retweets of any Tweet created by the user.)
  • リプライボタンを押さずに行われた手動リプライ (Manual replies, created without pressing a reply button (e.g. “@twitterapi I agree”).)

しかし、以下のツイートは含まれません。

  • 指定したユーザーにメンションしているツイート (Tweets mentioning the user (e.g. “Hello @twitterapi!”).)
  • リツイートボタンを押さずに行われた手動リツイート (Manual Retweets created without pressing a Retweet button (e.g. “RT @twitterapi The API is great”).)
  • 非公開アカウントによるツイート (Tweets by protected users.)

特に最後の「非公開アカウントによるツイート」の制約は厳しく、実験した限りではフォローしている鍵アカウントのツイートであっても取得することができません

Tweepyでの使い方

Streaming With Tweepyを読んでください。

followに自分がフォローしているアカウントと自分を指定して、取得できたツイートに含まれる画像を保存するスクリプトの例を示します。

# -*- coding: utf_8 -*-

import os
import sys
import urllib.request
from urllib.parse import urlparse
import tweepy


class MyStreamListener(tweepy.StreamListener):
    def on_status(self, status):
        if 'media' in status.entities:
            for media in status.extended_entities['media']:
                media_url = media['media_url']
                filename = os.path.basename(urlparse(media_url).path)
                if not os.path.exists(filename):
                    try:
                        urllib.request.urlretrieve(media_url, filename)
                        print("Saved :", filename)
                    except IOError:
                        print("Failed to save the image from", media_url, file=sys.stderr)


consumer_key = 'xxxxxxxxxxxxxxxxxxxxxxxxx'
consumer_secret = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
access_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
access_secret = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

auth = tweepy.OAuthHandler(consumer_key, consumer_secret)
auth.set_access_token(access_token, access_secret)
api = tweepy.API(auth)

followee_ids = api.friends_ids(screen_name=api.me().screen_name)
watch_list = [str(user_id) for user_id in followee_ids]
watch_list.append(str(api.me().id))

# followには5000人までしか指定できない
# https://developer.twitter.com/en/docs/tweets/filter-realtime/api-reference/post-statuses-filter
assert(len(watch_list) <= 5000)

myStreamListener = MyStreamListener()
myStream = tweepy.Stream(auth=api.auth, listener=myStreamListener)
myStream.filter(follow=watch_list)

Tweepyで画像を収集する (WIP)

機械学習をするためにはデータが必要なので、頑張って集める必要があります。 とりあえずTweepyで取れるだけ取る方法をいろいろ考えていきます。

Cursorを使う

Tweepyが標準で用意しているCursorというクラスを使ってタイムラインからRate Limitの許す限り画像を収集するコードは次のようになりました。

# -*- coding: utf_8 -*-

import os
import tweepy
import urllib.request
from urllib.parse import urlparse
from pprint import pprint


consumer_key = 'xxxxxxxxxxxxxxxxxxxxxxxxx'
consumer_secret = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
access_token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
access_secret = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'

auth = tweepy.OAuthHandler(consumer_key, consumer_secret)
auth.set_access_token(access_token, access_secret)
api = tweepy.API(auth, wait_on_rate_limit=True, wait_on_rate_limit_notify=True)

for tweet in tweepy.Cursor(api.home_timeline).items():
    if 'media' in tweet.entities:
        for media in tweet.extended_entities['media']:
            media_url = media['media_url']
            filename = os.path.basename(urlparse(media_url).path)
            try:
                urllib.request.urlretrieve(media_url, filename)
                print("Saved :", filename)
            except IOError:
                print("Failed to save the image from", media_url)

しかし、GET statuses/home_timelineは15分に15回しか呼び出すことができません(ドキュメント)。1回あたり20ツイート取得できるので15分で300件のツイートしか取得できないことになります。(countというパラメータがありますが、実験してみるとcountに大きい値を指定するとその分呼べる回数が減るようで、止まるまでに取得できるツイート数はあまり変わりませんでした。

タイムラインに常駐してツイートを収集しつづけるタイプのプログラムを書く必要がありますが、それはCursorでは実現できなさそうなので真面目に考える必要がありそうです。

友利奈緒判定botのときもRate Limitに悩まされたし、一生Rate Limitと戦っている……

明日以降のためのメモ

Illustration2VecをONNX経由で使う

趣味プロジェクトでIllustration2Vecを使いたくなったのですが、これは2015年の論文なのでモデルをCaffeかChainerで使うことになっています。 github.com

残念ながらCaffeもChainerも既に開発が終了しているため、Illustration2VecのモデルをONNXという共通フォーマットに変換して今後も使えるようにしました。 利用方法だけ知りたい人は「モデルの変換」を飛ばして「使い方」を見てください。

モデルの変換

まずはオリジナルのIllustration2Vecのモデルをダウンロードします。以下を実行するとCaffeのモデルがダウンロードできます。

git clone https://github.com/rezoo/illustration2vec.git
cd illustration2vec
./get_models.sh

このスクリプトでは特徴抽出モデルのprototxtがダウンロードできなかったので、Illustration2VecのInternet ArchiveからNetwork configuration file (feature vectors) illust2vec.prototxtを追加でダウンロードしました。

必要なライブラリをインストールします。

pip install onnx coremltools onnxmltools

以下のPythonスクリプトを実行すると、タグ予測モデルのillust2vec_tag_ver200.onnxと特徴ベクトル抽出モデルのillust2vec_ver200.onnxが作成されます。

import os
import onnx
import coremltools
import onnxmltools

# CaffeモデルをONNX形式で読み込む関数
# https://github.com/onnx/onnx-docker/blob/master/onnx-ecosystem/converter_scripts/caffe_coreml_onnx.ipynb


def caffe_to_onnx(proto_file, input_caffe_path):
    output_coreml_model = 'model.mlmodel'  # 中間ファイル名
    # 中間ファイルが既に存在したら例外を送出
    if os.path.exists(output_coreml_model):
        raise FileExistsError('model.mlmodel already exists')

    # CaffeのモデルをCore MLに変換
    coreml_model = coremltools.converters.caffe.convert(
        (input_caffe_path, proto_file))
    # Core MLモデルを保存
    coreml_model.save(output_coreml_model)
    # Core MLモデルを読み込む
    coreml_model = coremltools.utils.load_spec(output_coreml_model)
    # Core MLモデルをONNXに変換
    onnx_model = onnxmltools.convert_coreml(coreml_model)
    # Core MLモデルを削除
    os.remove(output_coreml_model)
    return onnx_model


# タグ予測モデルの変換・保存
onnx_tag_model = caffe_to_onnx(
    'illust2vec_tag.prototxt', 'illust2vec_tag_ver200.caffemodel')
onnxmltools.utils.save_model(onnx_tag_model, 'illust2vec_tag_ver200.onnx')

# 特徴ベクトル抽出モデルの変換・保存
onnx_model = caffe_to_onnx('illust2vec.prototxt',
                           'illust2vec_ver200.caffemodel')
# encode1レイヤーをONNXから利用できるようにする
# https://github.com/microsoft/onnxruntime/issues/2119
intermediate_tensor_name = 'encode1'
intermediate_layer_value_info = onnx.helper.ValueInfoProto()
intermediate_layer_value_info.name = intermediate_tensor_name
onnx_model.graph.output.extend([intermediate_layer_value_info])
onnx.save(onnx_model, 'illust2vec_ver200.onnx')

使い方

上のようにしてONNX形式に変換したモデルと、それを利用するためのコードを用意しました。 github.com

ONNX形式を他のフレームワークで読み込んで実行してもいいのですが、ONNX RuntimeというMicrosoft製のパフォーマンスを重視した推論専用のライブラリがあったのでこれを使うことにしました。 UbuntuでONNX RuntimeをCPU向けにインストールするコマンドは以下の通りです。

sudo apt install libgomp1
pip install onnxruntime

例を実行する前にコードとpre-trainedモデルのダウンロードを行ってください。

git clone https://github.com/kivantium/illustration2vec.git
cd illustration2vec
./get_onnx_models.sh

タグ予測

コード

import i2v
from PIL import Image
from pprint import pprint

illust2vec = i2v.make_i2v_with_onnx(
    "illust2vec_tag_ver200.onnx", "tag_list.json")

img = Image.open("images/miku.jpg")
pprint(illust2vec.estimate_plausible_tags([img], threshold=0.5))

入力

https://github.com/kivantium/illustration2vec/blob/master/images/miku.jpg?raw=true

Hatsune Miku (初音ミク), © Crypton Future Media, INC., http://piapro.net/en_for_creators.html. This image is licensed under the Creative Commons - Attribution-NonCommercial, 3.0 Unported (CC BY-NC).

出力

[{'character': [('hatsune miku', 0.9999994039535522)],
  'copyright': [('vocaloid', 0.9999999403953552)],
  'general': [('thighhighs', 0.9956372976303101),
              ('1girl', 0.9873461723327637),
              ('twintails', 0.9812833666801453),
              ('solo', 0.9632900953292847),
              ('aqua hair', 0.9167952537536621),
              ('long hair', 0.8817101716995239),
              ('very long hair', 0.8326565027236938),
              ('detached sleeves', 0.7448851466178894),
              ('skirt', 0.6780778169631958),
              ('necktie', 0.560835063457489),
              ('aqua eyes', 0.5527758598327637)],
  'rating': [('safe', 0.9785730242729187),
             ('questionable', 0.02053523063659668),
             ('explicit', 0.0006299614906311035)]}]

Chainer版とほとんど同じ結果が出力されました。Chainerではこの処理に6秒かかっていましたが、onnx-runtimeだと2秒で実行できたのでたしかにパフォーマンスにも優れているようです(ChainerではCaffeのモデルを変換する手間が掛かっているので1枚を処理する時間で比較するのは公平ではないですが)。

特徴ベクトルの抽出

コード

import i2v
from PIL import Image

illust2vec = i2v.make_i2v_with_onnx("illust2vec_ver200.onnx")

img = Image.open("images/miku.jpg")

# extract a 4,096-dimensional feature vector
result_real = illust2vec.extract_feature([img])
print("shape: {}, dtype: {}".format(result_real.shape, result_real.dtype))
print(result_real)

# i2v also supports a 4,096-bit binary feature vector
result_binary = illust2vec.extract_binary_feature([img])
print("shape: {}, dtype: {}".format(result_binary.shape, result_binary.dtype))
print(result_binary)

先ほどと同じ入力に対する出力

shape: (1, 4096), dtype: float32
[[ 7.474596    3.6860986   0.537967   ... -0.14563629  2.7182112
   7.3140917 ]]
shape: (1, 512), dtype: uint8
[[246 215  87 107 249 190 101  32 187  18 124  90  57 233 245 243 245  54
  229  47 188 147 161 149 149 232  59 217 117 112 243  78  78  39  71  45
  235  53  49  77  49 211  93 136 235  22 150 195 131 172 141 253 220 104
  163 220 110  30  59 182 252 253  70 178 148 152 119 239 167 226 202  58
  179 198  67 117 226  13 204 246 215 163  45 150 158  21 244 214 245 251
  124 155  86 250 183  96 182  90 199  56  31 111 123 123 190  79 247  99
   89 233  61 105  58  13 215 159 198  92 121  39 170 223  79 245  83 143
  175 229 119 127 194 217 207 242  27 251 226  38 204 217 125 175 215 165
  251 197 234  94 221 188 147 247 143 247 124 230 239  34  47 195  36  39
  111 244  43 166 118  15  81 177   7  56 132  50 239 134  78 207 232 188
  194 122 169 215 124 152 187 150  14  45 245  27 198 120 146 108 120 250
  199 178  22  86 175 102   6 237 111 254 214 107 219  37 102 104 255 226
  206 172  75 109 239 189 211  48 105  62 199 238 211 254 255 228 178 189
  116  86 135 224   6 253  98  54 252 168  62  23 163 177 255  58  84 173
  156  84  95 205 140  33 176 150 210 231 221  32  43 201  73 126   4 127
  190 123 115 154 223  79 229 123 241 154  94 250   8 236  76 175 253 247
  240 191 120 174 116 229  37 117 222 214 232 175 255 176 154 207 135 183
  158 136 189  84 155  20  64  76 201  28 109  79 141 188  21 222  71 197
  228 155  94  47 137 250  91 195 201 235 249 255 176 245 112 228 207 229
  111 232 157   6 216 228  55 153 202 249 164  76  65 184 191 188 175  83
  231 174 158  45 128  61 246 191 210 189 120 110 198 126  98 227  94 127
  104 214  77 237  91 235 249  11 246 247  30 152  19 118 142 223   9 245
  196 249 255   0 113   2 115 149 196  59 157 117 252 190 120  93 213  77
  222 215  43 223 222 106 138 251  68 213 163  57  54 252 177 250 172  27
   92 115 104 231  54 240 231  74  60 247  23 242 238 176 136 188  23 165
  118  10 197 183  89 199 220  95 231  61 214  49  19  85  93  41 199  21
  254  28 205 181 118 153 170 155 187  60  90 148 189 218 187 172  95 182
  250 255 147 137 157 225 127 127  42  55 191 114  45 238 228 222  53  94
   42 181  38 254 177 232 150  99]]

Chainer版と同じbinary vectorが出力されていました。

次回はこれを使ってイラストの機械学習をします。

広告コーナー