AWS Lambdaを使ってサーバレスでLINE BotとABEJA Platformを連携させてみた

ABEJA Platform Advent Calendar 2018の14日目です。


はじめに

ABEJA Platformは、データの蓄積・アノテーション・学習・デプロイ・再学習と機械学習に必要となる全ての面倒なプロセスを実行する基盤で、これらのインフラを用意する必要がないのが売りとなっています。学習した結果をWebAPIとしてデプロイすることで、様々な用途に利用できます。

といいつつ、実際にどんな感じで使うかの事例がないと分からない!という事もありますので、今回はLINE Botと連携してみようと思います。ユーザーが画像を送信したら、推論した結果を画像で返してみようと思います。しかも、運用でサーバを管理したくないよねってことで、AWS Lambdaを使ってサーバレスにもしちゃいます。

本日のソースコードはここに置きました。


前提

これまでに、ObjectDetectionの学習や推論に関する記事を書いてきましたので、今回は推論ができているという前提で始めようと思います。なので、記事の殆どはLINE BotとAWSの話になりますね。はじめに2日目の記事までができた前提でスタートしましょう。


ABEJA Platformで画像を返す方法

これまでの記事では、推論結果はjsonで返すことが殆どでした。しかし、推論結果は画像で返した方が見やすいですよね。まずは推論結果を画像で返す方法について述べます。

画像で推論結果を返すためには、ABEJA Platform SDKに含まれるResponseクラスを用います。SDKのインストールは、11日目の記事を参考にしてください。以下は入力されたnumpy形式の画像を、そのまま返す方法です。io.BytesIOに対して、画像を保存し、Responseクラスに入れてやります。Object Detectionについても、画像に矩形とラベル名を付与してやります。

import io

from PIL import Image
from abejamodelcore.user.datatypes import Response

def handler(_iter, ctx):
for img in _iter:
img = Image.fromarray(img)
output = io.BytesIO()
img.save(output, format='JPEG')
bimg = output.getvalue()
yield Response([bimg], metadata=[('Content-Type', 'image/jpeg')])


LINE Botの設定とAWS Lambdaの接続

本節で述べる内容はこことかこことかここなど、色々なところに乗っているので、そちらも参考にしてください。本記事はLINE Botの使い方よりもABEJA Platformとの接続をメインにしますので、LINEの部分は雑です。


LINE Botの設定

LINE Developerのサイトに行き、Botを作っていきましょう。

Kobito.mCXviZ.png

Kobito.yQ7AL2.png

3種類のうちMessageAPIを選択し、作成します。

Kobito.nc4SJi.png

Developer Trialを選択します。

Kobito.buLOEj.png

新しいチャンネルが出来ました!

Kobito.S3a28J.png


AWS Lambdaの作成

Kobito.y86dsu.png

あとでS3にアクセスするので、新しいロールを作っておきましょう。作成は、ロールの項目からカスタムロールの作成を選択すると新しいウィンドウが開きます。ひとまず適当に名前をつけてロールを作成します。

Kobito.8dJCmL.png

先ほどの画面で、作成したロールを選択し、Lambdaを作成しましょう。続いて、関数を作っていきます。一度ローカルのPCに戻り、ターミナル上でプログラムを作っていきます。npmは何とかしてインストールしてください。

$ mkdir ABEJAPlatformBot

$ cd ABEJAPlatformBot
$ npm install @line/bot-sdk

さらに、シンプルな会話プログラムを作りましょう。ここでは面倒なので認証関係の処理を諸々省きます。前半のif文は、LINE Botとの導通テストのための処理っぽいです。


index.js

'use strict';

const line = require('@line/bot-sdk');
const client = new line.Client({channelAccessToken: process.env.ACCESSTOKEN});

exports.handler = async function (event, context) {
let body = JSON.parse(event.body);
if (body.events[0].replyToken === '00000000000000000000000000000000') {
let lambdaResponse = {
statusCode: 200,
headers: { "X-Line-Status" : "OK"},
body: '{"result":"connect check"}'
};
context.succeed(lambdaResponse);
} else {
var ret = {
'type': 'text',
'text': 'Hello World!'
};
var response = await client.replyMessage(body.events[0].replyToken, ret);
let lambdaResponse = {
statusCode: 200,
headers: { "X-Line-Status" : "OK"},
body: '{"result":"completed"}'
};
context.succeed(lambdaResponse);
}
};


そして、作ったindex.jsと、node_modulesなどをzipに固め、Lambdaの画面でアップロードします。

$ zip -r archive.zip *

Kobito.E7jElm.png

アップロードのボタンからファイルを選択し、右上の保存ボタンを押すと、画面上に先ほどのプログラムが展開されます。

Kobito.Um39DA.png

さらに環境変数ACCESSTOKENを設定しましょう。ACCESSTOKENはLINE Developersのサイトのメッセージ送受信設定の項目で発行できます。

Kobito.5reA0G.png


ロールの設定

S3にアクセスできるようにロールを設定しておきましょう。IAMから下記のようにポリシーをアタッチします。

Kobito.jlcCop.png

Kobito.TazEJB.png

Kobito.d3rw1O.png


API Gatewayの設定

LINE BotとAWL Lambdaを繋ぐ際に、API Gatewayを仲介することとします。

Kobito.KVA1Uz.png

Kobito.HvM8y6.png

Kobito.R7uSSK.png


  • 統合タイプ = Lambda

  • Lambda プロキシ統合の使用 = チェック

  • Lambda リージョン = 先ほど作ったLambdaのリージョン

  • Lambda 関数 = 先ほど作った関数

Kobito.iSYkYS.png

続いて、メソッドリクエストから、HTTPリクエストヘッダーを設定します。


  • リクエストの検証 = なし

  • HTTP リクエストヘッダー = "X-Line-Signature"

を追加し必須にチェックします。

Kobito.aPMac6.png

最後にAPIをデプロイしましょう。ステージ名は何でも良いです。

Kobito.lTrcvc.png

Kobito.SyaBbd.png

ここで、URLの呼び出しを控えておきます。

Kobito.6k3tcs.png


LINE Botとの接続テスト

LINE DevelopersサイトのWebhook URLから確認をしてみましょう。なお、以下のように443を入れる必要があるとのことです(追記:なくても大丈夫だった)。また、Webhook送信も利用するにしてください。

https://XXXXXXXXXXX.execute-api.us-west-2.amazonaws.com:443/prod

上手くいけば、こんな感じで成功します。まぁ本記事もちょっと雑ですし、多分上手くいかないと思いますが、そこは他の記事も参考にして頑張ってください(僕も記事を書きながら小1時間ほどハマった)。

Kobito.8vhYG5.png


AWS LambdaとABEJA Platformの接続から画像の返答まで

LINE Botの中身をAWS Lambdaで実装してやります。LINE BotやAPI Gatewayは前節のままでOKですので、Lambdaを実装していきましょう。

ここでは、画像が送られてきた場合に、推論結果の画像を返すこととします。まずはメインとなるindex.jsです。認証関連については省略して、メインの部分だけ記載します。作りは非常にシンプルで、main.getMessageに、送られてきたjsonを投げます。getMessageは、LINEに対するメッッセージを返しますので、それをLINE Botに返送します。

'use strict';

const line = require('@line/bot-sdk');
const client = new line.Client({channelAccessToken: process.env.ACCESSTOKEN});

exports.handler = async function (event, context) {
let body = JSON.parse(event.body);
const replyToken = body.events[0].replyToken;

if (replyToken === '00000000000000000000000000000000') {
let lambdaResponse = {
statusCode: 200,
headers: { "X-Line-Status" : "OK"},
body: '{"result":"connect check"}'
};
context.succeed(lambdaResponse);
} else {
let data = body.events[0];

var ret = await getMessage(data);

await client.replyMessage(body.events[0].replyToken, ret);
let lambdaResponse = {
statusCode: 200,
headers: { "X-Line-Status" : "OK"},
body: '{"result":"completed"}'
};
context.succeed(lambdaResponse);
}
};

getMessageは、送られてきたデータが画像情報かどうかを判定し、画像情報であったら、(1)画像のバイナリをgetImageで取得し、(2)doDetectionで検出を行い、(3)saveImageで結果の画像をS3に保存しつつ、そのURLを返します。LINE Botで画像を返す場合は、画像のURLとサムネイルのURLを送ります。

var getMessage = async (data)=> {

switch (data.type){
case 'message':
if(data.message.type == 'image') {
var img = await getImage(data.message.id);
var result = await doDetection(img);
var url = await saveImage(result);
var ret = {
type: 'image',
originalContentUrl: url.originalURL,
previewImageUrl: url.resizedURL
};
return ret;
} else {
// if request is not image...
}
}
};

まずは、画像のIDから画像のバイナリを抽出します。LINEのアクセストークンを使い、LINE.Clientクラスを作成し、getMessageContentでバイナリを取得して返します。

var getImage = async function(messageId) {

return new Promise((resolve, reject) => {
client.getMessageContent(messageId).then((stream) => {
var content = [];
stream.on('data', (chunk) => {
content.push(new Buffer.from(chunk));
}).on('error', (err) => {
reject(err);
}).on('end', function(){
resolve(Buffer.concat(content));
});
});
});
};

ここでようやくABEJA Platformの出番。doDetectionはバイナリデータをABEJA Platformに送ります。今回は画像のバイナリが返ってくるので、それをそのまま返します。ABEJA Platformへのリクエストは、doRequest関数を用います。ABEJA Platfromに限らない話ですが、画像をリクエストで取得するときはencoding: nullを忘れると正しくバイナリが返ってこないことに注意しましょう。

var doDetection = async (data)=> {

const requestOptions = {
url: process.env.API_ENDPOINT_DETECTION,
method: "POST",
headers: {
'Content-Type':'image/jpeg',
},
auth: {
user:process.env.ABEJA_PLATFORM_USER_ID,
password: process.env.ABEJA_PLATFORM_USER_TOKEN
},
encoding: null,
body: data
};
var response = await doRequest(requestOptions);
var ret = new Buffer.from(response);
return ret;
};

doRequest関数はとてもシンプルで、requestしてるだけですね。

var doRequest = function (options) {

return new Promise(function (resolve, reject) {
request(options, function (error, res, body) {
if (!error && res.statusCode == 200) {
resolve(body);
} else {
reject(error);
}
});
});
};

さて、ABEJA Platformから返ってきた画像をS3に保存しつつ、サムネイル画像を作成しそれもS3に保存します。保存時のファイル名は時刻を用いることとします。saveImageToS3で画像を保存、resizeImageでサムネイルを作成します。getSignedUrlでダウンロードURLを作成します。

var saveImage = async (data)=> {

var nowDate = new Date();
var nowTime = nowDate.getTime();
var filename = nowTime + '.jpg';
await saveImageToS3(data, filename);

var resizedFilename = nowTime + '_thumbnail.jpg';
var resizedData = await resizeImage(data);
await saveImageToS3(resizedData, resizedFilename);

var originalURL = await getSignedUrl(filename);
var resizedURL = await getSignedUrl(resizedFilename);

var ret = {
'originalURL': originalURL,
'resizedURL': resizedURL
};
return ret;
};

S3への保存については、色々な記事に書かれているのと同様ですが、putObjectを使ってファイルを保存します。

var AWS = require('aws-sdk');

var s3 = new AWS.S3();

var saveImageToS3 = async (image, filename) => {
return new Promise((resolve, reject) => {
var params = {
Bucket: process.env.S3_BUCKET_NAME,
Key: filename,
Body: image
};
s3.putObject(params, function(err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
};

画像のリサイズは、gmというやつを使います。resizeして再度バイナリにします。

var gm = require('gm').subClass({ imageMagick: true });

var resizeImage = async function(image) {
return new Promise((resolve, reject) => {
gm(image).resize(240).toBuffer('jpg', function(err, buf) {
if (err) {
reject(err);
} else {
resolve(buf);
}
});
});
};

最後に、期限付きのURLを発行します。

var getSignedUrl = async (filename) => {

return new Promise((resolve, reject) => {
var params = {
Bucket: process.env.S3_BUCKET_NAME,
Key: filename,
Expires: 3600
};
s3.getSignedUrl('getObject', params, function (err, url) {
if (err) {
reject(err);
} else {
resolve(url);
}
});
});
};


その他設定

環境変数を設定します。プラットフォームのクレデンシャルから、エンドポイントまで設定してやります。

image.png

AWS Lambdaのタイムアウト設定を少し長めにとっておきましょう。

Kobito.FwTecq.png


実験

というわけで、作成したBotに画像を投げましょう!さて実際は、もうちょい色々認識したいよねってことで、YOLOv3を使いました。コードはここに置いておきます。とりあえず結果を以下に。

Kobito.fELy9f.png

Kobito.kKjRpf.png

で〜き〜た〜!!


まとめ

というわけで、ABEJA Platformを使って実際に色々遊んでみる!をやってみました。今回は画像でしたが、これ以外にも翻訳、OCR、画像編集など、色々な応用があると思います。是非とも試してみてくださいね!