kivantium活動日記

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

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

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が出力されていました。

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

DjangoでTwitter認証を行う

前回の記事のつづきです。 kivantium.hateblo.jp

今回はTwitterでのOAuth認証を実装して、タイムラインを読み込むところまで進めます。

ライブラリのインストール

認証に必要なsocial-auth-app-djangoというライブラリをインストールします。前回作ったプロジェクトのルートディレクトリで以下を実行します。

pipenv shell
pipenv install social-auth-app-django

Twitterアプリケーションの作成

Twitter Developerのページからアプリを作成して、API keyとAPI secret keyを入手します。他に解説記事がたくさんあると思うので詳しくは書きません。

コールバックURLの追加

Twitterアプリケーションに適切なコールバックURLを設定しないと403エラーが出ます。今回のディレクトリ構成だと、ローカルで実行する場合は

http://localhost:8000/complete/twitter/

Herokuで実行する場合は

https://<appname>.herokuapp.com/complete/twitter/

を追加する必要があります。複数追加できるので両方追加しておくといいと思います。

設定の変更

playground/settings.pyを以下のように変更します。

 (略)

 INSTALLED_APPS = [
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.staticfiles',
+    'hello',
+    'social_django',
 ]
 
 (略)

 TEMPLATES = [
     {
         'BACKEND': 'django.template.backends.django.DjangoTemplates',
         'DIRS': [],
         'APP_DIRS': True,
         'OPTIONS': {
             'context_processors': [
                 'django.template.context_processors.debug',
                 'django.template.context_processors.request',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
+                'social_django.context_processors.backends',
+                'social_django.context_processors.login_redirect',
             ],
         },
     },
 ]
 
 (略)
 
 AUTH_PASSWORD_VALIDATORS = [
 
 LANGUAGE_CODE = 'en-us'
 
-TIME_ZONE = 'UTC'
+TIME_ZONE = 'Asia/Tokyo'
 
 USE_I18N = True
 
 (略)
 
 STATIC_URL = '/static/'
 
 STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
 
+AUTHENTICATION_BACKENDS = [
+    'social_core.backends.twitter.TwitterOAuth',
+    'django.contrib.auth.backends.ModelBackend',
+]
+
+SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/'
+
 # Use different settings in local environment and Heroku
 # https://qiita.com/miler0528/items/1926e93ed97979f8e9fa

 (略)
 
 if not DEBUG:
     import django_heroku
     django_heroku.settings(locals())
+
+    SOCIAL_AUTH_TWITTER_KEY = os.environ['TWITTER_CONSUMER_KEY']
+    SOCIAL_AUTH_TWITTER_SECRET = os.environ['TWITTER_CONSUMER_SECRET']

playground/local_settings.pyの末尾に以下の内容を追記します。

SOCIAL_AUTH_TWITTER_KEY = 'XXXXXXXXXXXXXXXXXXXXXXXXX'
SOCIAL_AUTH_TWITTER_SECRET = 'XXXXXXXXXXXXXXXXXXXXXXXXX'

TWITTER_CONSUMER_KEYTWITTER_CONSUMER_SECRETには先ほど作成したAPI keyおよびAPI secret keyを入れます。

設定を変更したのでマイグレートを行います。

python manage.py migrate

認証システムの実装

hello/urls.pyを以下のように変更してログアウト画面を追加します。

-from django.urls import path
+from django.urls import path, include
+import django.contrib.auth.views
 
 from . import views
 
 urlpatterns = [
     path('', views.index, name='index'),
+    path('logout/',
+        django.contrib.auth.views.LogoutView.as_view(template_name = 'hello/logout.html'),
+        name='logout'),
 ]

playground/urls.pyに一行追記します。

 urlpatterns = [
     path('', include('hello.urls')),
+    path('', include('social_django.urls', namespace='social')),
 ]

hello/views.pyを次のように変更します。こうすることで、ログインしているかどうかでトップページにアクセスしたときの表示内容を変えることができます。

-from django.http import HttpResponse
-
+from django.shortcuts import render
+from social_django.models import UserSocialAuth
 
 def index(request):
-    return HttpResponse("Hello, world!")
+    if request.user.is_authenticated:
+        user = UserSocialAuth.objects.get(user_id=request.user.id)
+        return render(request,'hello/index.html', {'user': user})
+    else:
+        return render(request,'hello/index.html')

テンプレートの追加

テンプレートを入れるフォルダを作成します。

mkdir -p hello/templates/hello

hello/templates/hello/index.htmlを以下の内容で作成します。

<html>
  <head>
    <title>index</title>
  </head>
  <body>
  {% if request.user.is_authenticated %}
    <p>あなたはログインしています (screen_name: {{ user.access_token.screen_name }})</p>
    <a href="/logout"><button type="button">ログアウト</button></a>
  {% else %}
    <p>あなたはログインしていません</p>
    <button type="button" onclick="location.href='{% url 'social:begin' 'twitter' %}'">Twitterでログイン</button>
  {% endif %}
  </body>
</html>

hello/templates/hello/logout.htmlを以下の内容で作成します。

<html>
  <head>
    <title>ログアウト</title>
  </head>
  <body>
    <p>ログアウトしました</p>
    <p><a href="/"><button type="button">トップへ</button></a></p>
  </body>
</html>

以上の作業の結果、以下のようなディレクトリ構成になります。

playground/
├── Pipfile
├── Pipfile.lock
├── Procfile
├── db.sqlite3
├── hello
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── templates
│   │   └── hello
│   │       ├── index.html
│   │       └── logout.html
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── manage.py
├── playground
│   ├── __init__.py
│   ├── asgi.py
│   ├── local_settings.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── runtime.txt

ローカルでのテスト

python manage.py runserver

ブラウザでhttp://localhost:8000/にアクセスすると、ログインしていない場合の画面が表示されます。

f:id:kivantium:20200412154331p:plain:w250

Twitterでログイン」ボタンを押すとこんな感じの認証画面に遷移します。 f:id:kivantium:20200412153257p:plain:w600

「連携アプリを認証」ボタンを押すと、ログインしてトップ画面に戻ります。ログインしている場合はscreen_nameが表示されます。

f:id:kivantium:20200412154738p:plain:w400

ログアウトボタンを押すとログアウトされます。

f:id:kivantium:20200412154920p:plain:w200

デプロイ

ローカル環境で正しく動いたらHerokuにデプロイします。

heroku config:set TWITTER_CONSUMER_KEY='XXXXXXXXXXXXXXXXXXXXXXXXX'
heroku config:set TWITTER_CONSUMER_SECRET='XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
git add .
git commit -m 'Add login with Twitter'
git push heroku master
heroku run python manage.py migrate

https://<appname>.herokuapp.com/にアクセスすると、ローカルと同じようなアプリが動くはずです。

タイムラインの読み込み

これでTwitter APIを使う準備が整ったので、試しにタイムラインの読み込みを行ってみます。 Twitter APIを扱うライブラリには以前の記事でも取り上げたtweepyを使うことにします。

tweepyのインストール

pipenv install tweepy

hello/views.pyの変更内容

 from django.shortcuts import render
 from social_django.models import UserSocialAuth
+from django.conf import settings
+
+import tweepy
 
 def index(request):
     if request.user.is_authenticated:
         user = UserSocialAuth.objects.get(user_id=request.user.id)
-        return render(request,'hello/index.html', {'user': user})
+        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)
+        timeline = api.home_timeline()
+        return render(request,'hello/index.html', {'user': user, 'timeline': timeline})
     else:
         return render(request,'hello/index.html')

/hello/templates/hello/index.htmlの変更内容

   <body>
   {% if request.user.is_authenticated %}
     <p>あなたはログインしています (screen_name: {{ user.access_token.screen_name }})</p>
+    <ul>
+    {% for tweet in timeline %}
+    <li>@{{ tweet.user.screen_name }}: {{ tweet.text }}</li>
+    {% endfor %}
+    </ul>
     <a href="/logout"><button type="button">ログアウト</button></a>
   {% else %}
     <p>あなたはログインしていません</p>

変更をコミットしてgit push heroku masterでデプロイすると、タイムラインから最新20件のツイートを読み込んで表示することができるようになります。

f:id:kivantium:20200412153845p:plain

次回はいつになるか分かりませんが、Twitterを使った何かしらのアプリを作ってみようと思います。

参考文献

DjangoアプリをHerokuにデプロイする

インターンを始めたら労働のつらさを思い出しました。

というわけで、Webサービスの作り方を勉強していきたいと思います。

WebサービスといえばRuby on Railsというイメージがあったのですが、最近は人気が落ちているという話もよく聞きます。Rubyの経験が全くない自分が今から勉強する必要はないのではないかと思って調べてみたら、Rails・Django・Laravelを3大Webフレームワークと呼んでいる記事を発見しました。このうちのDjangoPythonで書かれているため今までの経験を活かすことができ、機械学習ライブラリなどと組み合わせるのもラクそうだったのでこれを使ってみようと思いました。 f:id:kivantium:20200412000515p:plain:w600

(グラフはStack Overflow Trendsより)

Ruby on Railsチュートリアルの第1章ではHello, world!をHerokuにデプロイしていたため、Djangoでも同じことをやってみようとしたら意外と情報が錯綜していて時間がかかったので自分がやった方法を記録しておきます。DjangoもHerokuも今週始めたばかりです。あまり信用しないでください。

実行環境

環境構築

Deploying Python and Django Apps on Heroku によれば、ルートディレクトリにrequirements.txt, setup.py, Pipfile のいずれかがある場合にPythonによるアプリだと認識されるとのことです。一番モダンそうなpipenによるパッケージ管理を使うことにしました。

適当なディレクトリで以下のコマンドを実行します。playgroundは好きな名前にしてください。

$ django-admin startproject playground
$ cd playground
$ pipenv --python 3.6
$ pipenv shell
$ pipenv install django-heroku gunicorn

ホームディレクトリで上のコマンドを実行したとすると、この時点での~/playground/ディレクトリの構成は以下のようになっているはずです。

~/playground
├── Pipfile
├── Pipfile.lock
├── manage.py
└── playground
    ├── __init__.py
    ├── asgi.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

以下、manage.pyがあるディレクトリを「ルートディレクトリ」と呼び、ファイルパスは全てそこからの相対パスで表します。playgroundではない名前をつけた場合は適当に読み替えてください。

ファイルを書き換える

まず、settings.pyと同じフォルダに以下の内容のlocal_settings.pyを作成します。こうすることで開発環境と本番環境でデバッグの有無などを切り替えることができるようになります。SECRET_KEYの行は環境ごとに違うので、playground/settings.pyからコピペしてください。(参考: pipenvでDjangoアプリをherokuにでデプロイしたので手順をメモ

DEBUG = True

SECRET_KEY = '_or(2t#$a9fm9i0_-620e(kv__qz6%%wzwx$j0*+ayo1xd&2d+'

次に、playground/settings.py を書き換えます。編集前後のdiffを示します。赤が削除した行で、青が追加した行です。

 (略)

 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 # Quick-start development settings - unsuitable for production
 # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
 
-# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = '_or(2t#$a9fm9i0_-620e(kv__qz6%%wzwx$j0*+ayo1xd&2d+'
 
 # SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
+DEBUG = False
 
 ALLOWED_HOSTS = []
 
 (略)
 
 INSTALLED_APPS = []
 
 MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
+     'whitenoise.middleware.WhiteNoiseMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',

 (略)
 
USE_TZ = True
 # https://docs.djangoproject.com/en/3.0/howto/static-files/
 
 STATIC_URL = '/static/'
+# 2020/04/26 追記
+# http://whitenoise.evans.io/en/stable/django.html#make-sure-staticfiles-is-configured-correctly
+STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
+
+# Simplified static file serving.
+# https://warehouse.python.org/project/whitenoise/
+
+STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
+
+# Use different settings in local environment and Heroku
+# https://qiita.com/miler0528/items/1926e93ed97979f8e9fa
+try:
+    from .local_settings import *
+except ImportError:
+    pass
+
+if not DEBUG:
+    import django_heroku
+    django_heroku.settings(locals())
  • SECRET_KEYは公開してはいけない情報なのでコードからは削除します。実行するたびに値が違うはずなので適宜読み替えてください。
  • DEBUGは本番環境ではFalseにします。
  • MIDDLEWARESTATICFILES_STORAGE関係の設定はWhiteNoiseを使って静的ファイルを効率的に扱えるようにするためのものです。(参考: Django and Static Assets
  • MIDDLEWAREへの追記はSecurityMiddlewareの直後にする必要があるので注意してください。(参考: enable-whitenoise
  • コードの末尾に追加したのは開発環境と本番環境の設定を切り替えるための処理です。

最後にデプロイに必要なProcfileをルートディレクトリに用意します。playground.wsgiの部分はプロジェクト名に合わせて適宜変更してください。(参考: Configuring Django Apps for Heroku

web: gunicorn playground.wsgi

Pythonのバージョンを指定する場合はルートディレクトリにruntime.txtを用意します。用意しない場合はデフォルトのバージョンが使われます。執筆時点でのデフォルトは3.6.10です(参考: specifying-a-python-version

python-3.6.10

この時点でのディレクトリ構成は以下のようになっているはずです。

playground
├── Pipfile
├── Pipfile.lock
├── Procfile
├── manage.py
├── playground
│   ├── __init__.py
│   ├── asgi.py
│   ├── local_settings.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── runtime.txt

一度ローカル環境で動作を確認してみます。ルートディレクトリで以下を実行します。

python manage.py runserver

ブラウザでhttp://127.0.0.1:8000/にアクセスすると、いい感じのロケットの画像が表示されるはずです。 f:id:kivantium:20200411235809p:plain:w600

Hello, world!

Hello, worldを実行するためのアプリを作成します。

python manage.py startapp hello

hello/views.pyを以下のように編集します。

from django.http import HttpResponse


def index(request):
    return HttpResponse("Hello, world!")

以下の内容のhello/urls.pyを作成します。

from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
]

さらに、playground/urls.pyを以下のように編集します。

from django.urls import include, path

urlpatterns = [
    path('', include('hello.urls')),
]

python manage.py runserverを実行してブラウザでhttp://127.0.0.1:8000/にアクセスすると、Hello, world!が表示されるはずです。

デプロイの準備

Herokuのインストール

The Heroku CLIに従ってインストールします。Ubuntuではsnapでインストールできます。

sudo snap install --classic heroku

初めての場合はユーザー登録をする必要があります。公開鍵を登録しておくと便利です。

heroku login --interactive
heroku keys:add

公開鍵を登録したら、以下を実行します。appnameには好きな名前を設定してください。

heroku login
heroku create <appname>

settings.pyから削除したSECRET_KEY環境変数として設定しておきます。 以下のコマンドを実行してください。(SECRET_KEYの中身は環境に応じて変えてください)

heroku config:set SECRET_KEY='_or(2t#$a9fm9i0_-620e(kv__qz6%%wzwx$j0*+ayo1xd&2d+'

デプロイ

HerokuにはGit経由でデプロイします。ルートディレクトリに以下のような.gitignoreを用意します。

__pycache__
*.pyc
db.sqlite3
staticfiles
local_settings.py

ルートディレクトリで以下を実行します。

git init
git add .
git commit -m 'first commit'
git push heroku master
heroku ps:scale web=1

https://<appname>.herokuapp.com/にアクセスするとHello, world!が表示されたページが確認できると思います。

(pushできなかった場合: Herokuにpush時にdoes not appear to be a git repository出た時の対処 - Qiita

ここまでの作業をまとめたリポジトリはこのコミットなので参考にしてください。 github.com

次回Twitterによるログイン機能を実装します。

参考文献

広告コーナー