画像を識別をするwebアプリ (Django)

概要

東方Projectのキャラクター画像の識別器のWebアプリをDjangoを使って作成しました。
東方のキャラクター画像識別器

イントロ

画像識別器を作成してから(前回記事)、折角だから分かりやすい形で残したいと思ったのでWebアプリという形にしようかなと思いました。
思い立った時点では、Webアプリなんて一度も作ったこともなんてないしHTMLも勉強したこともない、なんかPythonを使えるWebフレームワークがあってそれを使えばWebアプリ作れるのは知ってるという程度の知識でした。
とりあえずDjangoの公式チュートリアルDjango Girlsのチュートリアルをやってみました。
これらのチュートリアルを終えて、簡単な画像識別器アプリくらいなら作れるなと思ったのでやってみました。

目標

東方のキャラクター画像をアップロードしたらそのキャラクターの識別をするサイト。
アップロードした画像のプレビューが表示されて、識別結果としてキャラクター名とスコアの表が表示される感じにしたい。

実装

Djangoフレームワークに沿って実装していきます。
Djangoのバージョンは4.0です。

urls.py

from django.urls import path
from django.conf.urls.static import static

from . import views

app_name = 'classifier'

urlpatterns = [
    path('', views.ClassifierView.as_view(), name='classifier'),
]

app_nameclassifierとしました。

データベースは使わないので、models.pyは無し。データベース使わないのにDjango使う意味あるの

views.py

from django.shortcuts import render
from django.views import generic
from django import http
from django.conf import settings
import json
import io
import base64
from PIL import Image

from .forms import ImageForm
from classifier import classifier

# Create your views here.
class ClassifierView(generic.TemplateView):
    template_name = 'classifier/classifier.html'
    classifier_model = classifier.init_model()
        
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['form'] = ImageForm()
        context['pred'] = ''
        context['done'] = False
        context['image'] = None
        context['alert'] = None
        self.classifier_model = classifier.init_model()

        return context

    def get(self, request, *args, **kwargs) -> http.HttpResponse:
        return super().get(request, *args, **kwargs)

    # label to name
    def label_to_name(self, labels, chara_table):
        # int or str
        if type(labels) == int or type(labels) == str:
            name = ""
            try:
                for k, v in chara_table.items():
                    if v['id'] == int(labels):
                        name = k
                        break
            except ValueError:
                pass
            return name
        else:
            return ""

    def post(self, request, *args, **kwargs):
        form = ImageForm(request.POST, request.FILES)
        if not form.is_valid():
            context = self.get_context_data()

            # Only file size <= 50MB is acceptable
            if 'File size over' in form.errors['image']:
                context['alert'] = '50MB以下のファイルのみ実行します'
            else:
                context['alert'] = 'ファイルを正常に読み込めませんでした'

            # raise ValueError('invalid form')
            return render(request, self.template_name, context=context)

        image = form.cleaned_data['image']

        # Preview image
        with io.BytesIO() as buf:
            Image.open(image, mode='r').save(buf, format='PNG')
            img = buf.getvalue()
        img = base64.b64encode(img)    # encode the buffer valuess by base64
        img = img.decode("utf-8")    # decode to image
        self.kwargs['image'] = img

        # Character name string
        table_file = settings.STATIC_ROOT + '/classifier/chara_table.json'
        chara_table = dict()
        with open(table_file, "r") as f:
            chara_table = json.load(f)

        # Generate return values
        preds = classifier.pred(image, model=self.classifier_model)
        top_labels = {}
        value_prev = 1.0
        for n, idx in enumerate(reversed(preds.argsort())):
            value = preds[idx]
            # Display at least 3 classes. Display classes which scores > 0.1.
            # Break when score is less than half of previous one or the difference is less than 0.005.
            if (n > 3) and (value < 0.1) and (((value_prev / value) > 2.0) or (value_prev - value < 0.005)):
                break
            top_labels[self.label_to_name(int(idx), chara_table)] = value
            value_prev = value
        self.kwargs['pred'] = top_labels
        self.kwargs['done'] = True

        return render(request, self.template_name, context=self.kwargs)

このviews.pyは主に画像投稿postの挙動を定義しています。
postを簡単に説明すると、forms.py(下に記載してます)に定義されたImageFormクラスで画像を読み込んでから、画像をプレビュー。
chara_table.jsonというキャラクターのIDと名前を対応させたファイルを読み込み。
classifierクラスの学習済の識別器モデルを呼び出して、predメソッドで推測結果を出力してます。
推測結果の予測値が高いものをcontextに格納してテンプレートに渡し、render実行。
推測結果のうち表示するクラスは、スコアの降順に並べ、最低3クラスを表示、4つ目以降はスコアが0.1より高く、スコアが一つ前のクラスの半分以上かつ差が0.005未満のクラスならば表示。

forms.py

from django import forms
from django.forms import ValidationError

def size_limit_validator(value):
    file_size= value.size
    if file_size > 52428800:    # 50MB = 52428800
        raise ValidationError('File size over')

class ImageForm(forms.Form):
    image = forms.ImageField(validators=[size_limit_validator])

画像投稿Form。
過負荷防止のため、validatorsに50MB以上の画像を受け付けないようにする処理を追加。

classifier.html

<!-- ### 前略 ### -->
    <h1>
        東方のキャラクター画像識別器
    </h1>
    <p>
        東方Projectのキャラクターが描かれた画像をアップロードするとどのキャラクターの画像かを識別します。
        <br>
        50MB以下のファイルのみ受け付けます。
    </p>
    
{% if not done %}
    <table>
        <form method="POST" enctype="multipart/form-data">
        {% csrf_token %}
        {{ form }}
        <tr>
            <td></td><td><input type="submit" value="実行する" /></td>
        </tr>
        </form>
    </table>
    {% if alert %}
    <div class="alert alert-danger" role="alert">
        <p>{{ alert }}</p>
    </div>
    {% endif %}
{% else %}
<p><img src="data:image/png;base64,{{ image | safe }}" class="img-fluid"></p>
<div class="table-responsive">
    <table style="max-width: 400px;" class="table table-light table-striped table-hover caption-top">
        <thead class="table-light">
        <tr>
            <th>Name</th>
            <th>Score(0~1)</th>
        </tr>
        </thead>
        <tbody>
        {% for key, value in pred.items %}
        <tr>
            <td>{{ key }}</td>
            <td class="w-25">{{ value |floatformat:3}}</td>
        </tr>
        {% endfor %}
        </tbody>
    </table>
</div>
<p>
    <a href="" class="btn btn-primary">もう一度する</a>
</p>
{% endif %}
<!-- ### 後略 ### -->

説明文を記載して、未実行の場合は画像アップロードフォームと識別実行ボタンを表示。
識別器が動いた後はimageに格納されたプレビュー用データを表示して、識別結果の表を表示(bootstrapを使ってるので簡単に書けます)。もう一度するボタンを表示。

デプロイ

2022.10.06 追記
初稿時はHerokuにデプロイしていましたが、Renderに移行しました。

さて、折角作ったのでwebに公開したいですが、これもやり方全然知りませんでした。
最も簡単なのはDjango Girlsチュートリアルで触れたPythonAnywhereですが、試してみたらPyTorchの容量が大きすぎるせいで入らない!
別の手段を探すとherokuというサービスでも同じことが出来るようなので、herokuで試してみます。

Django deploy heroku」とかでググれば沢山解説記事出てくるので引っかかったところだけ書きます。
requirements.txtにPyTorchのインストール時のリンクを指定します。(サイズオーバーを回避できます。)

--find-links https://download.pytorch.org/whl/cpu/torch_stable.html
torch==1.11.0+cpu
torchvision==0.12.0+cpu

セキュリティ上、SECRET_KEYをアプリ内部に置かないようにするため、HerokuのアプリのSettingのページからConfig VarsにSECRET_KEYを設定し、settings.pyで環境変数から読み込むようにする。

SECRET_KEY = os.environ['SECRET_KEY']

herokuにデプロイしたのが、こちらのページです。
東方のキャラクター画像識別器

2022.10.06 追記
Renderに移行しました。
Renderへのデプロイ記事はこちら
ウェブページは東方のキャラクター画像識別器

動かしてみます。

識別に使った画像は 東方虹龍洞 (上海アリス幻樂団) より

ちゃんと動いてるようですね。良かった。