"BOKU"のITな日常

還暦越えの文系システムエンジニアの”BOKU”は新しいことが大好きです。

Base64データをURLで渡すWeb-API作成時の注意点と対策/django+Nuxt

JavaScriptBase64エンコードした画像データをWeb-APIにURLパラメータで渡して、分類結果をJSON形式で受け取る処理を書く場合のポイントと注意点です。

f:id:arakan_no_boku:20190909212423j:plain

 

はじめに

 

今回の主な内容というかポイントは以下の点です。

 

Base64エンコードしたテキストをそのままURLパラメータにできない理由

 

Base64エンコードデータはテキストです。

だから、なんとなくURLパラメータにそのまま渡せるような気がします。

でも・・うまくいかないのですね。

なぜかというと。

URL内で特別な意味を持つ、「+」 と「 /」 がBase64テキストに含まれるからです。

特に「+」が問題です。

そのままURLのパラメータに渡すと、勝手に「+」が空白に置き換えられます。

なので、サーバー側でBase64からデコードすると、「異常な画像データで例外が発生」してしまいます。

ユーザサイドからみると「500 Internal Server Error」なんかになるわけです。

 

Base64エンコードテキストをURLパラメータに渡せる方法

 

どうすれば良いか?です。

やることは単純です。

Base64エンコードテキストに含まれる「/」や「+」を、悪さをしない別の文字に置き換えて、URLパラメータに渡し、受取側(サーバー側)で元に戻してやる処理をすればよいわけです。

たとえば、「/」を「!」に、「+」を「-」に置き換えるとかですね。

この置換文字のヒントは、Wikipediaからもらいました。

ja.wikipedia.org

 

JavaScriptで画像をBase64変換したものはそのままでは使えない理由

 

これでOK・・と思いきや。

JavaScriptで画像ファイルを読んで、Base64エンコードテキストにしたものは、実は、そのままではデコードできないデータになっています。

どういうことかというと。

readAsDataURL(file)でドロップされた画像ファイルを読んで、reader.resultでBase64エンコードした結果を受け取る・・というのが、JavaScript側でBase64エンコードする「よくある方法」です。

arakan-pgm-ai.hatenablog.com

なんですが。

この時、reader.resultで受け取れるBase64フォーマットには、<img>タグで表示するために必要な「data:image/jpeg;base64,」とか「data:image/png;base64,」がくっついています。

HTML表示には、とても便利なのですが、そのままでは100%デコードに失敗します。

なので。

サーバー側でデコードさせるためには、事前にそれを削除しておく必要があるというわけです。

 

具体的に対策を施したソースコードサンプルと解説

 

上記の注意点を対策した実例を、具体的なソースコードで書いてみます。

サンプルとして、「URLパラメータでBase64エンコードした画像データを受け取り、分類結果をJSON形式で返すWEB-APIを作成し、それをJavaScriptから使ってみるというシナリオをやります。

Web-API作成にはDjango REST Framework (以後、DRF)」を使います。

DRFのインストール・設定などは、こちらの記事のようにできていることを前提にしています。

arakan-pgm-ai.hatenablog.com

あと、Web-APIを作る時につかうViewなどについては、こちらの記事の内容にそって実装します。

arakan-pgm-ai.hatenablog.com

サーバー側でBase64をデコードして画像の分類結果を返す機能は、こちらの記事で作成したものを使います。 

arakan-pgm-ai.hatenablog.com

さて。

 

 

まずはPythonDjango(サーバー側)のソースです

 

 

まずは、DRF側です。

まず、ソースをのせて、後でポイントを補足します。

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from . import tf_hub_mobilenet_v2 as mn


class PredictImageFromBase64(APIView):

    def get(self, request):
        img_quoted = request.GET.get(key="img", default="")
        encode_dic = {'!': '/', '-': '+'}
        encode_table = str.maketrans(encode_dic)
        image_unquoted = img_quoted.translate(encode_table)
        out_dict = {}
        if(len(image_unquoted) <= 20000):
            out_dict['message'] = '224×224サイズ以上の画像データが対象です。'
            out_dict['result'] = '入力が正しくないため、分類処理ができませんでした。'
            out_dict['status'] = 'Param NG'
        else:
            cl = mn.MobileNetImageNet()
            out_dict['message'] = ''
            out_dict['result'] = cl.predict_from_base64(str(image_unquoted))
            out_dict['status'] = 'Param OK'
        return Response(out_dict, status=status.HTTP_200_OK)

画像の分類処理自体は「cl.predict_from_base64(str(image_unquoted))」一発です。

最大のポイントは以下の部分です。

img_quoted = request.GET.get(key="img", default="")
encode_dic = {'!': '/', '-': '+'}
encode_table = str.maketrans(encode_dic)
image_unquoted = img_quoted.translate(encode_table) 

 

ここで、Base64エンコードテキストに含まれる「/」や「+」を、悪さをしない別の文字に置き換えるという処理をしています。

str.maketransの使い方については、こちらにまとめてます。

arakan-pgm-ai.hatenablog.com

結果はJSONで返してるのですが、どこにもJSONに変換している処理が見えない・・と不思議かもしれませんが、これはDRFの機能を使っているからです。

辞書型のデータをResponseに渡すと、自動的に美しいJSONフォーマットに変換してくれるので、特に自前でやる必要はありません。

f:id:arakan_no_boku:20190914113924p:plain

間違っても、自分でjson.dumpsなどをしてResponseに渡さないよう(笑)

今回は、urls.pyに以下のように登録しました。

from django.urls import path
from . import views
from . import predict_image_from_base64 as pred

urlpatterns = [
    path(
        'api/v1/image/classification',
        pred.PredictImageFromBase64.as_view(),
        name='pred-v'),
]

これでPython側はOKです。

 

JavaScript側のソースとポイントの整理

 

まずは、Web-APIをコールして結果を返すプラグインです。

plugins/predictImageResult.js

import Vue from 'vue'
import axios from 'axios'

Vue.prototype.$predictImageResult = (base64Image) => {
  const url = `http://localhost:8000/api/v1/image/classification?img=${base64Image}`
  return axios
    .get(url)
    .then((res) => {
      return res.data.result
    })
}

axiosを使ったプラグインの作り方とかは、こちらの記事を参考にしてます。

arakan-pgm-ai.hatenablog.com

まあ、特に変わったことはありません。

DRSのResponseで返すJSONは、red.dataでこちらが定義したout_dicの内容がとれるので、そのうちの「result」だけを返しているくらいですかね。

 

ポイント1:URLパラメータに使えない「+」と「/」を置き換える処理

 

さて、分類するボタンを押した時にonClick()で呼ばれる処理です。 

    async clickTest () {
      const translated = this.images_decodable[0].replace(/\//g, '!').replace(/\+/g, '-')
      this.classResult = await this.$predictImageResult(translated)
    }
    

async・await を使って、プラグインをコールしてるだけなのですが、パラメータで受け取ったBase64フォーマットをうけとって、「/」を「!」に、「+」を「-」に置き換えている部分が、今回のポイントです。

このClickTestを、どっか適当なボタンに紐づけて、適当な画像をBase64形式にしたものを渡します。

 

ポイント2:JavaScriptで画像をBase64変換したものをデコード可能にする処理

 

画像をドラッグ&ドロップでうけとって、Base64形式にエンコードする処理です。

基本は、以下の記事の内容をそのまま使ってます。

arakan-pgm-ai.hatenablog.com

でも、上記の記事の処理と違って、余分な情報を消す処理を追加してます。。

 

        reader.onload = () => {
            const res = reader.result
            this.images.push(res)
            if (fileList[i].type === 'image/jpeg') {
              this.images_decodable.push(String(res).replace('data:image/jpeg;base64,', ''))
            } else {
              this.images_decodable.push(String(res).replace('data:image/png;base64,', ''))
            }
          }

reader.resultで受け取れるBase64フォーマットから「data:image/jpeg;base64,」とか「data:image/png;base64,」をreplaceで消してます。

上記は、入力をfile.typeでこの2つの形式以外は処理しないようにして、決め打ちにしてますが、汎用的にする場合はもう少し工夫が必要になります。

で・・・。

当然ですが、画像分類のWeb-APIにパラメータで渡すのは、上記の情報を削除した「this.images_decodable」の方です。

 

やってみた結果はこんな感じ

 

画像の一覧を表示するときに、クリックすると対象の画像を分類するボタンを組み込んでみました。

f:id:arakan_no_boku:20190915224637p:plain


ここで一番上のボタンを押して、しばらく待つと。

f:id:arakan_no_boku:20190915224757p:plain

ちょっと見づらいですけど、こんな感じで分類メッセージをちゃんと受け取ってます。

images_a.jpg/この画像はtiger cat:日本語名(猫(ジャガー猫/トラ猫))です

良い感じです。

ちなみに。

自分のPC環境では、pythonでの分類処理でtensorflow/kerasを起動するオーバーヘッドに時間がかかるので、ボタン押してから結構待つ必要があります。

なので、一気に複数分類とかはあきらめました。

 

最後に、上記の動作確認につかったVueファイルを掲載しときます

 

ソース全文です。

前の方にポイントは書いているので、それ以外に特に補足説明はしてません。

pages/image_predict.vue

<template>
  <div class="container content has-text-centered">
    <div class="columns">
      <div class="column" />
      <div class="column">
        <NLink to="/">
          <p>ルートのページへ戻ります</p>
        </NLink>
      </div>
      <div class="column" />
    </div>
    <div class="columns">
      <div class="column" />
      <div class="column is-harf">
        <div class="card">
          <div class="card-content" @dragleave.prevent="checkDrag($event, 'new', false)" @dragover.prevent="checkDrag($event, 'new', true)" @drop.prevent="onDrop">
            <div class="drop is-primary">
              <p>
                {{ msg1 }}
              </p>
              <label for="file_selection">
                {{ msg2 }}
                <input id="file_selection" type="file" accept="image/*" style="display:none" @change="onDrop">
              </label>
            </div>
          </div>
        </div>
      </div>
      <div class="column" />
    </div>
    <div class="columns">
      <div class="column" />
      <div class="column is-four-fifths">
        <p class="has-text-weight-semibold is-size-4 has-text-centerd">
          {{ classResult }}
        </p>
      </div>
      <div class="column" />
    </div>
    <div v-if="files != null">
      <div v-for="(file, index) in files" :key="index">
        <div class="columns">
          <div class="column">
            <figure class="image is-128x128">
              <img :src="images[index]">
            </figure>
          </div>
          <div class="column is-one-third">
            <div v-if="isExistData === true">
              <b-button @click="clickTest(index)">
                <p class="has-text-weight-semibold is-size-4 has-text-centerd">
                  {{ file.name }}は何の画像か分類する
                </p>
              </b-button>
            </div>
          </div>
          <div class="column">
            <p class="has-text-weight-semibold is-size-4 has-text-centerd">
              {{ file.type }}
            </p>
          </div>
          <div class="column">
            <p class="has-text-weight-semibold is-size-4 has-text-centerd">
              {{ file.size.toLocaleString() }} Byte
            </p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  data () {
    return {
      isDrag: null,
      msg1: 'ここにファイルをドロップ。または',
      msg2: 'ここをクリックして選択',
      files: [],
      images: [],
      images_decodable: [],
      isExistData: false,
      classResult: '',
      test: ''
    }
  },
  methods: {
    checkDrag (event, key, status) {
      this.isDrag = status ? key : null
      if (status) {
        this.msg1 = 'ドラッグ中'
        this.msg2 = 'ここにドロップしてください'
      } else {
        this.msg1 = 'ここにファイルをドロップ。または'
        this.msg2 = 'ここをクリックして選択'
      }
    },
    onDrop (event) {
      this.isDrag = null
      const fileList = event.target.files
        ? event.target.files
        : event.dataTransfer.files
      for (let i = 0; i < fileList.length; i++) {
        if (fileList[i].type === 'image/jpeg' || fileList[i].type === 'image/png') {
          this.isExistData = true
          const reader = new FileReader()
          reader.onload = () => {
            const res = reader.result
            this.images.push(res)
            if (fileList[i].type === 'image/jpeg') {
              this.images_decodable.push(String(res).replace('data:image/jpeg;base64,', ''))
            } else {
              this.images_decodable.push(String(res).replace('data:image/png;base64,', ''))
            }
            this.classResult.push('')
          }
          reader.readAsDataURL(fileList[i])
        } else {
          this.images.push('/jamap.JPG')
        }
        this.files.push(fileList[i])
      }
      this.msg1 = 'ドロップされました'
      this.msg2 = 'ファイル数は' + fileList.length + 'です。'
    },
    async clickTest (index) {
      const translated = this.images_decodable[index].replace(/\//g, '!').replace(/\+/g, '-')
      const res = await this.$predictImageResult(translated)
      this.classResult = this.files[index].name + '/' + res
    }
  }
}
</script>

 

今回はこんなところで。

ではでは。