DjangoとTensorFlowを利用した、顔画像の推論

# はじめに
- `Django` と `TensorFlow` を使い、顔画像の推論を実施します。
- 最終的に `Heroku` へデプロイする事を考慮しました。`Django` なら、データベースや CSRF 対策など、機能が充実しているからですね。
- ソース一式は [ここ](https://github.com/maedamikio/public/tree/master/abe_or_ishihara/django) です。



# 概要
- フロント
    - Bootstrap
    - jQuery
    - SpinKit https://github.com/tobiasahlin/SpinKit
- バックエンド
    - Django
    - NumPy
    - OpenCV
    - Pillow
    - TensorFlow
- ストレージ
    - S3


# ライブラリ
- 以下を `pip` でインストールします。

```requirements.txt
boto3
django
numpy==1.16.5
opencv-python
pillow
tensorflow==1.14.0
```

# Django

## セットアップ
- まず、プロジェクトとアプリの雛形を作成します。

```shell
$ django-admin startproject project .
$ python manage.py startapp app
```

- 上記で作成した `app` を追加する。
- テンプレートのフォルダを追加する。
- 日本語への修正をする。
- タイムゾーンの修正をする。
- スタティックファイルのフォルダを追加する。

```project/settings.py
INSTALLED_APPS = [
    'app.apps.AppConfig',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,


LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

STATIC_URL = '/static/'
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]
```

- `manage.py` が存在するフォルダと同じレベルで、上記で設定したスタティックファイルとテンプレートのフォルダを作成する。

```
$ mkdir static templates
```

- デバッグ用のデータベースを作成します。
- デフォルトでは、`SQLite` が利用され、`db.sqlite3` が作成されます。

```shell
$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying sessions.0001_initial... OK
```

- 管理者ユーザーを作成する。

```shell
$ python manage.py createsuperuser
ユーザー名 (leave blank to use 'maeda_mikio'):
メールアドレス:
Password:
Password (again):
Superuser created successfully.
```

- 管理画面を起動します。

```shell
$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
December 21, 2019 - 14:09:05
Django version 3.0.1, using settings 'project.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
```

- ブラウザで `http://127.0.0.1:8000/` にアクセスし、設定したユーザー名とパスワードを入力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/08044d30-fa0c-e42f-7955-97fffdcc6da9.png)

- ブラウザで `http://127.0.0.1:8000/admin` にアクセスします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/6a887f0f-79b4-edbf-6f7d-0663b8d4a178.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/99139c3c-a704-afac-2233-dfbcb9dbd446.png)

## トップ画面を作成
- `app/views.py` にトップ画面のテンプレートを設定します。

```app/views.py
def index(request):
    return render(request, 'index.html')
```

- 上記の `index.html` は `templates/index.html` に作成します。
- テンプレートは、`Bootstrap` を利用しました。
- 画像をドラッグ&ドロップ出来る様にしています。`static/index.js` で処理をしています。
- また、推論の処理中は、[SpinKit](https://github.com/tobiasahlin/SpinKit) でアニメ処理をしています。`static/spinner.css` で処理をしています。

```templates/index.html
  <head>
省略
    <!-- Spinner CSS -->
    <link rel="stylesheet" href="/static/spinner.css">
省略
  </head>

  <body>
省略
    <div class="starter-template container">
      <img src="/static/title.png" class="img-fluid"><br />
      <img id="img" src="/static/abe_or_ishihara.png" class="img-fluid" height="400">
      <div class="spinner" style="display: none;">
        <div class="rect1"></div>
        <div class="rect2"></div>
        <div class="rect3"></div>
        <div class="rect4"></div>
        <div class="rect5"></div>
      </div>
    </div>
    <input type="file" id="file" name="file" accept="image/*" style="display: none;" required>
   {% csrf_token %}

省略
    <script src="/static/index.js"></script>
  </body>
```

## 画像の送信
- Django の `CSRF` 対策を利用しています。
- `index.html` 内の `{% csrf_token %}` からトークンを取得します。
- こちらは、Django公式ドキュメントをそのまま利用しています。

```static/index.js
// https://docs.djangoproject.com/en/3.0/ref/csrf/
function getCookie(name) {
  var cookieValue = null;
  if (document.cookie && document.cookie !== '') {
    var cookies = document.cookie.split(';');
    for (var i = 0; i < cookies.length; i++) {
      var cookie = jQuery.trim(cookies[i]);
      // Does this cookie string begin with the name we want?
      if (cookie.substring(0, name.length + 1) === (name + '=')) {
        cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
        break;
      }
    }
  }
  return cookieValue;
}

var csrftoken = getCookie('csrftoken');

function csrfSafeMethod(method) {
    // these HTTP methods do not require CSRF protection
    return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}
$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken);
        }
    }
});
```

- 画像は、クリックしてファイルを選択する、もしくはドラッグ&ドロップで選択することにしました。
- スマホなら、タップが使えるはずです。

```static/index.js
// クリックしてファイルを選択
$('#img').on('click', function() {
  $('#file').click();
});

$('#file').change(function() {
  var files = this.files;
  if (checkImg(files)) {
    file = files[0];
    readImg(file);
    predict(file);
  }
});


// ファイルをドラッグ&ドロップ
var img;

img = document.getElementById('img');
img.addEventListener('dragenter', dragenter, false);
img.addEventListener('dragover', dragover, false);
img.addEventListener('drop', drop, false);

function dragenter(e) {
  e.stopPropagation();
  e.preventDefault();
}

function dragover(e) {
  e.stopPropagation();
  e.preventDefault();
}

function drop(e) {
  e.stopPropagation();
  e.preventDefault();

  var dt = e.dataTransfer;
  var files = dt.files;
  if (checkImg(files)) {
    file = files[0];
    readImg(file);
    predict(file);
  }
}
```

- 画像は、同時に複数選択出来るため、1ファイルに限定する様にしています。
- また、`jpeg` `png` のみとしました。
- サイズも 10MB 以上は処理をしないことにします。

```static/index.js
// 1ファイル以上、jpeg、png、10MB以上の場合は処理をしない
function checkImg(files) {
  if (files.length != 1 ) {
    return false;
  }
  var file = files[0];
  console.log(file.name, file.size, file.type);
  if (file.type != 'image/jpeg' && file.type != 'image/png') {
    return false;
  }
  if (file.size > 10000000) {
    return false;
  }
  return true;
}
```

- ファイルが条件が問題なければ、ファイルを読み込みます。

```static/index.js
// ファイルの読み込み
function readImg(file) {
  var reader = new FileReader();
  reader.readAsDataURL(file);
  reader.onload = function() {
    $('#img').attr('src', reader.result);
  }
}
```

- Django で作成する `/api/` パスに画像を送信します。
- 結果を受信するまでは、[SpinKit](https://github.com/tobiasahlin/SpinKit) でアニメ処理をします。

```static/index.js
// 推論API
function predict(file) {

  $('#img').css('display', 'none');
  $('.spinner').css('display', '');

  var formData = new FormData();

  formData.append('file', file);

  $.ajax({
    type: 'POST',
    url: '/api/',
    data: formData,
    processData: false,
    contentType: false,
    success: function(response) {
      console.log(response);
      $('#img').attr('src', response);
      $('.spinner').css('display', 'none');
      $('#img').css('display', '');
    },
    error: function(response) {
      console.log(response);
      $('#img').attr('src', '/static/abe_or_ishihara.png');
      $('.spinner').css('display', 'none');
      $('#img').css('display', '');
    }
  });
}
```

## 推論API
- 処理中のログをデータベースに保存するためのモデルを生成します。

```app/views.py
def api(request):

    log = Log()
```

- 画像は、`POST` で送信されているかチェックします。
- また、事前に `Django` が `CSRF` チェックをしている形になります。

```app/views.py
    if request.method != 'POST':
        log.status = 'post error'
        log.save()
        raise Http404
```

- 画像を読み込みます。ファイル名、ファイルサイズも取得します。

```app/views.py
    try:
        formdata = request.FILES['file']
        filename = formdata.name
        filesize = formdata.size
    except Exception as err:
        log.message = err
        log.status = 'formdata error'
        log.save()
        return server_error(request)
    log.filename = filename
    log.filesize = filesize
    log.save()
```

- ファイルサイズは、フロント側でチェックしていますが、もう一度チェックします。

```app/views.py
    if filesize > 10000000:
        log.status = 'filesize error'
        log.save()
        return server_error(request)
```

- ファイルデータを取得し、`jpeg` `png` のチェックをします。

```app/views.py
    try:
        filedata = formdata.open().read()
    except Exception as err:
        log.message = err
        log.status = 'filedata error'
        log.save()
        return server_error(request)

    ext = imghdr.what(None, h=filedata)
    if ext not in ['jpeg', 'png']:
        log.message = ext
        log.status = 'filetype error'
        log.save()
        return server_error(request)
```

- 後で `Heroku` で公開をする予定です。
- `Heroku` いわゆるオブジェクトストレージがないため、`AWS S3` へ保存することにしました。

```app/views.py
    try:
        s3_key = save_image(filedata, filename)
    except Exception as err:
        log.message = err
        log.status = 's3 error'
        log.save()
        return server_error(request)
    log.s3_key = s3_key
    log.save()
```

- `S3` には、日付とファイル名で保存します。
- アクセスキー、バケット等は、環境変数から読みだす様にします。

```app/views.py
def save_image(filedata, filename):

    now = (datetime.datetime.utcnow() + datetime.timedelta(hours=9)).strftime('%Y-%m-%dT%H:%M:%S+09:00')
    key = '{}_{}'.format(now, filename)
    resource = boto3.resource('s3', aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'], aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY'])
    resource.Object(os.environ['BUCKET'], key).put(Body=filedata)

    return key
```

- 画像データを `OpenCV` で扱える様に　`NumPy` で変換します。
- その後、`HAAR Cascade` で顔認識をします。
- また、`app/haarcascade_frontalface_default.xml` に保存しています。

```app/views.py
    image = np.fromstring(filedata, np.uint8)
    image = cv2.imdecode(image, 1)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    face_cascade = cv2.CascadeClassifier(os.path.join(os.path.dirname(__file__), 'haarcascade_frontalface_default.xml'))
    faces = face_cascade.detectMultiScale(gray, scaleFactor=1.3, minNeighbors=5)

    if type(faces) != np.ndarray:
        log.status = 'faces error'
        log.save()
        return server_error(request)
```

- 顔認識出来た場合は、`TensorFlow` で推論出来る様にデータをリサイズ、グレースケール等に変換します。
- 複数の顔に対応しています。

```app/views.py
    face_list = []
    for (x, y, w, h) in faces:
        face = image[y:y+h, x:x+w]
        face = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY)
        face = cv2.resize(face, (IMG_ROWS, IMG_COLS))
        face = np.array(face, dtype=np.float32) / 255.0
        face = np.ravel(face)
        face_list.append(face)
```

- `TensorFlow` で学習したモデルで、推論を実施します。
- 学習したモデルは、`app/data/` に保存しています。

```app/views.py
    try:
        percent_list = predict(face_list, dtype='int')
    except Exception as err:
        log.message = err
        log.status = 'predict error'
        log.save()
        return server_error(request)
```

- 顔の推論結果に基づいて、元の画像に修正を加えます。
- 今回、10人の顔画像の分類があって、0番目と1番目は特別に、青色、赤色で識別出来る様にしました。
- 顔は、`cv2.rectangle` で四角で選択され、下側に `write_text` で推論結果を表示します。

```app/views.py
    predict_list = []
    for (x, y, w, h), percent in zip(faces, percent_list):
        max_index = np.argmax(percent)
        max_value = np.amax(percent)
        if max_index == 0:
            color = (177, 107, 1)
        elif max_index == 1:
            color = (15, 30, 236)
        else:
            color = (0, 0, 0)
        text = '{} {}%'.format(CLASSES[max_index], max_value)
        image = write_text(image, text, (x, y+h+10), color, int(h/10))
        cv2.rectangle(image, (x, y), (x+w, y+h), color, thickness=2)
        predict_list.append(text)
    log.message = ','.join(predict_list)
```

- 推論結果を画像に描きたいのですが、`OpenCV` では、日本語が利用できません。
- そこで、`Pillow` を利用します。
- 最初に、`OpenCV` から `Pillow` の形式へ変換します。
- フォントサイズも、顔のサイズに合わせて、適当に修正します。
- フォントは、[コーポレート・ロゴ](https://logotype.jp/corporate-logo-font-dl.html) のフリーフォントを利用しました。
- フォントは `app/font.ttf` に保存しています。

```app/views.py
def write_text(image, text, xy, color, size):

    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    image = Image.fromarray(image)

    fill = (color[2], color[1], color[0])
    size = size if size > 16 else 16
    font = ImageFont.truetype(os.path.join(os.path.dirname(__file__), 'font.ttf'), size)
    draw = ImageDraw.Draw(image)
    draw.text(xy, text, font=font, fill=fill)

    image = np.array(image, dtype=np.uint8)
    image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)

    return image
```

- 最後に、画像データを `BASE64` に変換し、フロントへ送信します。

```app/views.py
    image = cv2.imencode('.jpeg', image)[1].tostring()
    response = 'data:image/jpeg;base64,' + base64.b64encode(image).decode('utf-8')

    log.status = 'success'
    log.save()

    return HttpResponse(response, status=200)
```

## URLのマッピング
- トップページの `/` と 推論APIの `/api/` を追加しました。

```project/urls.py
from app import views

urlpatterns = [
    path('', views.index, name='index'),
    path('api/', views.api, name='api'),
    path('admin/', admin.site.urls),
]

```

## ログ保存のモデルを作成
- 今回は、ログの保存のためのモデルを作成します。
- 推論APIで `log = Log()` や `log.save()` などで記載していた箇所です。
- ログの作成日時、更新日時、受信したファイル名、ファイルサイズ、S3のファイルキー、ステータスと状況に応じてメッセージを追加出来るようにしました。

```app/models.py
class Log(models.Model):

    create = models.DateTimeField(auto_now_add=True)
    update = models.DateTimeField(auto_now=True)

    filename = models.CharField(max_length=100, null=True, blank=True)
    filesize = models.IntegerField(null=True, blank=True)
    s3_key = models.CharField(max_length=100, null=True, blank=True)

    message = models.TextField(null=True, blank=True)
    status = models.CharField(max_length=100, null=True, blank=True)
```

- ログを管理画面から確認出来るようにします。
- カラム、カラムのフィルター、メッセージに関しては検索出来るようにしました。

```app/admin.py
class LogAdmin(admin.ModelAdmin):

    list_display = ('id', 'create', 'status', 'filename', 'filesize', 's3_key', 'message')
    list_filter = ['create', 'status']
    search_fields = ['message']


admin.site.register(Log, LogAdmin)
```

## ログのモデルのマイグレーション
- 作成したログのモデルをデータベースへ反映します。

```shell
$ python manage.py makemigrations app
Migrations for 'app':
  app/migrations/0001_initial.py
    - Create model Log

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, app, auth, contenttypes, sessions
Running migrations:
  Applying app.0001_initial... OK
```

## 管理サイトでログを確認
- `http://127.0.0.1:8000//admin/app/log/` 下記のような画面が準備できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/743e25a8-4950-7a80-a275-1efb666ba8e7.png)


# S3、IAMの設定

## IAM
- `Django` の推論API の `def save_image(filedata, filename):` でファイルを保存しています。
- `AWS_ACCESS_KEY_ID` と `AWS_SECRET_ACCESS_KEY` を取得します。
- `IAM` のポリシーは、データの保存(PutObject) のみ許可しました。
- 大量のバケットは、`abe-or-ishihara` にしました。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::abe-or-ishihara/*"
            ]
        }
    ]
}
```

## S3
- バケットは、`abe-or-ishihara` にしました。
- リージョンは、米国東部(バージニア北部)にしました。
- `Heroku` の `us` リージョンにデプロイする予定です。`us` は、バージニアなので、距離的に近い方が良いでしょう。


# 起動シェル
- `AWS` の`IAM` `S3` の情報を環境変数に設定した上で、`Django` の開発サーバーを起動します。

```runserver.sh
#!/bin/bash

export AWS_ACCESS_KEY_ID='your-aws-access-key'
export AWS_SECRET_ACCESS_KEY='your-aws-secret-access-key'
export BUCKET=abe-or-ishihara

python manage.py runserver 0.0.0.0:5000
```


# 推論のテスト
- 画像をドラッグ&ドロップしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/6079be0c-d271-0216-54fc-7b8a39b96de1.png)

![download.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/1f5b593c-3a92-a7aa-1861-1b91b0c61155.jpeg)

![download-1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/79cb3187-4c93-b7e2-081a-777a6c81b1b1.jpeg)

- 管理サイトでログを確認してみます。
- 左上では、`MESSAGE` の内容を検索できます。右側では、推論APIへのアクセス日時で絞り込んだり、ステータスで絞り込んだりできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/3e0cd010-aa06-b9f6-825e-07debc516934.png)


- `S3` にも保存できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68632/4cea8a95-3eee-78b6-6cc3-d0d6df33dc24.png)


# おわりに
- `TensorFlow` で学習したモデルを使い、`Django` で推論アプリケーションを作成しました。
- `Flask` よりは、難しいですが、`CSRF` 対策、データベース等の機能が使えるのが便利ですね。
- 次は、`Heroku` へデプロイを実施する予定です。