はじめに
AWS Chalice を用いてAPIを開発した際、multipart/form-data の形式でアップロードされた画像ファイルの取り扱いにつまずきました。
先駆者様の記事を見つけたものの、2021年3月現在ではうまく動きません
他の解決策を探した結果、GitHubの issue を参考に解決できたため、備忘も兼ねて手法を記載します。
コード例
Chalice の基本は理解しているものとして、主要部分のみを記載します。
Chalice については、こちらのAWS公式ハンズオン記事 がファーストステップとしておすすめです。
▼Chalice - app.py
import cgi
from chalice import Chalice
from io import BytesIO
# Chalice設定
app = Chalice(app_name='APP_NAME')
# API Gateway に multipart/form-data をバイナリとして扱うように指示する設定 (ここがポイント!!)
app.api.binary_types.append('multipart/form-data')
@app.route('/upload', methods=['POST'], content_types=['multipart/form-data'], cors=True)
def upload():
# 受け取った情報をバイナリデータとして保存
body_binary = BytesIO(app.current_request.raw_body)
# cgi.FieldStorageクラスを用いて、フォームの内容を解析
environ = {'REQUEST_METHOD': 'POST'}
headers = {'content-type': app.current_request.headers['content-type']}
form = cgi.FieldStorage(fp=body_binary, environ=environ, headers=headers)
# 指定したファイルをバイナリ形式で取得('uploadfile'は送信時に設定した当該ファイルのnameの値)
file_binary = form.getvalue('uploadfile')
# 以降、任意の処理を記述
▼index.html (アップロード元の例)
コード内、** API Gateway URL をここに貼り付け! **
に作成した API の URL を上書きしてください。
「/」以下のパスまで忘れずに記載ください。
(例:https://XXXXXXX.execute-api.ap-northeast-1.amazonaws.com/api/upload
) ← 「/upload」まで忘れずに!
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>TITLE</title>
</head>
<body>
<form action="** API Gateway URL をここに貼り付け! **"
enctype="multipart/form-data"
method="POST">
<input type="file" name="uploadfile" />
<input type="submit"/>
</form>
</body>
</html>
コードのポイント
2021年3月現在、先駆者様の記事を参考にしても、Chaliceでは画像ファイルなどのバイナリファイルをうまく扱えません。
これは、API Gateway のデフォルト設定が、 multipart/formdata 形式をバイナリとして扱わない設定になっているためです。この設定のため、API Gateway は、http の body の部分を UTF-8 で再エンコーディングしてしまい、アップロードされたファイルを壊してしまいます。
参照:multipart/form-data support in AWS Chalice · Issue #796 · aws/chalice
解決方法としては、上記 issue に記載の通り、app.api.binary_types.append()
メソッドを使います。
これにより、 multipart/form-data 形式で送られる body 部分をバイナリとして扱うように API Gateway に明示的に設定することができます。
参照:Chalice — Python Serverless Microframework for AWS 1.15.1 documentation
上記の app.py の中では以下の箇所でその設定をしています。
# API Gateway に multipart/form-data をバイナリとして扱うように指示する設定 (ここがポイント!!)
app.api.binary_types.append('multipart/form-data')
おまけ: 【ハンズオン】有名人識別サービスを Chalice で作ろう!
以前、【AWSハンズオン】サーバレスアーキテクチャで、有名人識別サービスを作ろう! - Qiita という記事を投稿させていただきました。
せっかくなので、今回の記事の内容を活用して、Chalice版で上記サービスを作り直してみましょう。
フロントには Vue.js も使用していますが、わからなくても動かせる内容になっています。ご安心ください。
私の GitHub リポジトリ からクローンする方法もあります。この場合の手順はリポジトリの README を参照してください。
作るもの(動作イメージ)
有名人の画像(例えばガッキー)の画像をアップロードすると、Yui Aragaki 100%
と表示されるシンプルな有名人の識別サービスです。
作るもの(アーキテクチャ)
- クライアントPCから API Gateway 経由で有名人の画像を Lambda にアップロードします。
- 画像ファイルの受信をトリガーに、Lambda 関数が起動します。
- Lambda 関数内で Amazon Rekognition の recognize_celebrities 機能に画像ファイルを送り、画像内の有名人を識別します。
- 取得した有名人の情報を出力用に整形して、API Gateway を通して返します。
- 呼び出し元のブラウザ上で、識別結果が表示されます。
ソースコード
$ chalice new-project rekognition-handson-chalice-v2
をした状態を仮定します。
以下、主要ファイルについてのみ記載します。 詳細なコードは私の GitHub リポジトリ を参照してください、
▼Lambda側:Chalice - app.py
import logging
import traceback
import boto3
import cgi
from chalice import Chalice, BadRequestError
from io import BytesIO
# logger設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Chalice設定
app = Chalice(app_name='rekognition-handson-chalice-v2')
# API Gateway に multipart/form-data をバイナリとして扱うように指示する設定
app.api.binary_types.append('multipart/form-data')
# rekognition インスタンスの作成
rekognition = boto3.client('rekognition')
@app.route('/upload', methods=['POST'], content_types=['multipart/form-data'], cors=True)
def upload():
# 受け取った情報をバイナリデータとして保存
body_binary = BytesIO(app.current_request.raw_body)
# cgi.FieldStorageクラスを用いて、フォームの内容を解析
environ = {'REQUEST_METHOD': 'POST'}
headers = {'content-type': app.current_request.headers['content-type']}
form = cgi.FieldStorage(fp=body_binary, environ=environ, headers=headers)
# ファイルをバイナリ形式で取得('uploadfile'は送信時に設定した当該ファイルのnameの値)
file_binary = form.getvalue('uploadfile')
# 取得した画像ファイルを、Rekognitionに渡して有名人の識別をする
response = rekognition.recognize_celebrities(
Image={'Bytes': file_binary}
)
logger.info(f'Rekognition response = {response}')
try:
# Rekognition のレスポンスから有名人の名前と確度を取り出し、APIのコール元へレスポンスする。
label = response['CelebrityFaces'][0]
name = label['Name']
conf = round(label['Face']['Confidence'])
output = { 'name': name, 'confidence': conf }
logger.info(f'API response = {output}')
return output
except IndexError as e:
# Rekognition のレスポンスから有名人情報を取得出来なかった場合、他の写真で試すように伝える。
logger.warning(f"Coudn't detect celebrities in the Photo. Exception = {e}")
logger.warning(traceback.format_exc())
raise BadRequestError("Couldn't detect celebrities in the uploaded photo. Please upload another photo.")
▼フロント側:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Rekognition-Handson</title>
</head>
<body>
<div id="app">
<h2>有名人識別ハンズオン</h2>
<input type="file" @change="onChange">
<div v-if="imagePreview">
<hr/>
<button @click="onSubmit">画像解析を実行</button><br/><br/>
<img :src="imagePreview" width="300"/>
</div>
<div v-if="isLoading">
<p>画像を送信中...</p>
</div>
<div v-if="celebrityInfo.name">
<p>名前:{{ celebrityInfo.name }}</p>
<p>確度:{{ celebrityInfo.confidence }} %</p>
</div>
<div v-if="celebrityInfo.errorMessage">
<p>{{ celebrityInfo.errorMessage }}</p>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.21.1/dist/axios.min.js"></script>
<script src="main.js"></script>
</body>
</html>
▼フロント側:main.js
コード内、** API Gateway URL をここに貼り付け! **
に作成した API の URL を上書きしてください。
「/」以下のパスまで忘れずに記載ください。
(例:https://XXXXXXX.execute-api.ap-northeast-1.amazonaws.com/api/upload
) ← 「/upload」まで忘れずに!
let app = new Vue({
el: '#app',
data: {
imageFile: '',
imagePreview: '',
celebrityInfo: '',
isLoading: false,
},
methods: {
onChange: function(event) {
this.celebrityInfo = '';
this.imageFile = event.target.files[0];
if (this.imageFile && this.imageFile.type.match(/^image\/(png|jpeg)$/)) {
this.imagePreview = window.URL.createObjectURL(this.imageFile);
}
},
onSubmit: function(event) {
this.isLoading = true;
this.celebrityInfo = '';
const targetUrl = '** API Gateway URL をここに貼り付け! **';
const formData = new FormData();
formData.append('uploadfile', this.imageFile);
var config = {
headers: {
'content-type': 'multipart/form-data',
}
};
const that = this;
axios
.post(targetUrl, formData, config)
.then(response => {
that.celebrityInfo = response.data;
})
.catch(error => {
console.error(error);
that.celebrityInfo = {errorMessage: '画像から有名人を認識できませんでした。他の画像に変更してください。'}
})
.finally(() => {
that.isLoading = false;
});
}
}
})
HTMLファイルの開き方
今回、JavaScript を利用していることもあり、index.html の開き方について注意点が3つあります。
- index.html と main.js は同じディレクトリに配置してください。
- Internet Explorer では動作しません。(ES2018の文法を一部使用しているため、Chrome などを推奨します。)
- index.html は http スキーマでブラウザから読み込んでください。 ← 重要!
3点目について説明をします。
index.html ファイルをブラウザにドラッグ&ドロップして開いた場合、アップロード処理が正常に動作しません。
これは、index.html ファイルを file スキーマの形式でブラウザから読み込んだ場合、Same Origin Policy のセキュリティー制限によって、画像アップロードの JavaScript 処理が正常に動かないためです。
そのため、以下の方法でローカルサーバーを立て、http スキーマで index.html ファイルにアクセスする必要があります。
Pythonを用いて簡単にサーバーを起動できます。index.html と main.js が存在するディレクトリで、ターミナルから以下のコマンドを実行してください。
$ ls #ファイルの確認
index.html main.js
$ python -m http.server 8080
サーバー起動後に、ブラウザからhttp://0.0.0.0:8080/
へアクセスしてください。作成したHTMLファイルが開けるはずです。
サーバーを停止する場合は Ctrl + C
で止めてください。
なお、index.html や main.js を更新した際、サーバーを再起動しても更新後のファイルがロードされないことがあります。
その際はターミナルを一度停止し、開き直してサーバーも再起動することで解決します。
動作デモ
YouTube に動作画面をアップロードしました。
思い当たる有名人の画像を数枚集めてテストしてみたところ、一通り認識できているようです。(さすがAmazon Rekognition!)
ただし、キムタクは認識できませんでした。 個人的にはガッキーよりも有名な気がしますが...(注:諸説あります)
まとめ
以上、AWS Chalice にて、 multipart/form-data 形式でアップロードされたバイナリファイルを扱う方法についてでした。
おまけのハンズオンが本編よりも長く脱線してしまいましたが、お役に立てれば幸いです!