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画像検索のようにサムネイルだけ表示してあとはツイートへのリンクにする方式にすることも含めて検討していきたいです。

広告コーナー