クソアプリ Advent Calendar 2019 3日目の記事です。
前置き
おはようございます。DE-TEIUです。
もはや日本の一大イベントと言っても差し支えないクソアプリ Advent Calendarの季節がやって参りました。
僭越ながら去年に引き続き今年も失笑間違いなしのクソアプリを皆様にお届けします。
過去に作ったやつ
成果物
昨今、スマートフォンとその中で動作するアプリケーションの進化によって、自撮り写真を加工してSNSに投稿するという行為はもはや一般的なものになっています。
私もその文化の流れに乗るべく、自撮りと加工を一度に行えるWebアプリを開発いたしました。
※カメラ付きのPCまたはスマートフォンでアクセスしてください。
(撮影の際は、なるべく正面向きで顔全体が映る状態で撮影してください。)
自撮り写真が
加工されてしまったと思います。
SNS等に投稿する際は覚悟を決めてから行ってください。
アプリの構成
- フロントエンドはNuxt.jsで開発し、Firebase Hostingで公開
- アプリで撮影した画像を一時的にFirebase Storageに保存
- Firebase Storageに保存した画像のURLを、Zeit Now内で動かしているサーバーに送信
- Now内のNode.jsアプリで受け取った画像のURLをAzureのFace APIに送信
- 顔認識結果をFace API → Now → クライアントに返す
- クライアント側でユーザーに鼻毛などを生やす
##Zeit Nowを経由してFace APIを呼ぶ理由
まず、やろうと思えばフロントエンドのJavaScriptから直接FaceAPIにリクエストを送信して処理結果を受け取ることは可能です。ただし、リクエストを送信するには、Azureから発行されたAPIキーをパラメータとして渡す必要があります。
そのため、APIキーをソース内に、あるいは参照可能な別ファイルに定義しておく必要があります。
しかしながら、フロントエンドのソースコードは誰でも参照可能なため、この方法ではAPIキーを抜き取られる恐れがあります。
というわけで、何か適当なサーバーサイドアプリを実装し、APIキーはそのアプリ内に配置して、
そのアプリ経由でFaceAPIにアクセスする必要があります。
ちなみに、Cloud Functions for FirebaseでもNode.jsで開発したサーバサイドアプリを動かすことができますが、Firebaseの無料枠だとFunctionsから外部のAPIを呼ぶことができません。
というわけで、今回はNowを利用してFaceAPIを呼び出すサーバサイドアプリを開発しました。
サーバサイドアプリから外部のAPIを呼び出したい場合は、Firebaseに課金するかNowを使いましょう。
解説
以下、実装の解説等です。
##Microsoft Azure Face APIで顔検出
Microsoft AzureにはCognitive Servicesというサービスがあり、これを利用すると機械学習等の専門知識が無くとも画像解析、音声認識、文章解析などの技術を活用したアプリを開発できます。
(ただ使うだけならAzureのポータルからCognitive ServicesのAPIのキーを発行し、アプリからそのAPI呼ぶだけです。今回はそれです。)
今回は、Cognitive Services内のFace APIを使用しました。
これによって、画像に写った人物の顔情報(目や鼻の位置など)を取得できます。
今回は使用していませんが、このAPIで人物の表情、性別、年齢なども検出できるようです。
参考
今回は、Node.js(express)で開発したサーバサイドのAPIからFace APIを呼び出しています。
const port = process.env.PORT || 3000;
const express = require('express');
const app = express();
const axios = require("axios");
const apiRequest = axios.create({
baseURL: "https://nose-detect.cognitiveservices.azure.com/face/v1.0/detect",
headers: {
'Content-Type': 'application/json',
'Ocp-Apim-Subscription-Key': 'ここにAzureのポータルから発行したAPIのキーを入れる',
}
});
app.use(express.json());
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "ここにAPIの呼び出しを許可するオリジンを指定");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
app.listen(port, err => {
if (err) throw err
});
app.post('/getfaceinfo', (req, res) => {
//FaceAPIに渡すパラメータを指定
//(どんな情報を取得したいか等。今回は顔のパーツの座標情報を取ってくるようにします)
const baseParam = "?returnFaceId=true&returnFaceLandmarks=true&returnFaceAttributes=headPose";
//Face APIにリクエスト送信
apiRequest.post("/" + baseParam, {
"url": req.body.url //顔認識をしたい画像のURLを渡す
}).then((apiRes) => {
res.send(apiRes.data);
}).catch((error) => {
res.status(500);
res.send("error");
});
})
参考
##APIをZeit Nowにデプロイ
Nowとは、ZEIT社が運営しているPaas(ホスティングサービス)です。
Node.js、Go、Python、Rubyで開発したサーバサイドアプリケーションを簡単にデプロイできます。
(HTML、CSS、JavaScriptをデプロイすれば静的サイトの公開も可)
デプロイに必要な手順は以下の通りです。(Node.jsの場合)
- npmでnowをグローバル環境にインストール
- now.json(設定ファイルを開発したNode.jsプロジェクト内に作成
- nowコマンドを実行してデプロイ
- メールアドレスで認証
これだけです。
詳しい手順は、以下の記事と公式のドキュメントをご参照ください。
###参考
##Nuxt.jsでフロントエンド開発
フロントエンドはNuxt.jsとVuetifyを組み合わせて実装しました。
Vuetifyは見た目も機能も良い感じのコンポーネントが充実しているのでおすすめです。
###Firebase Hostingでデプロイ
Nuxt.jsで開発したフロントエンドのコードは、今回はFirebase Hostingを使ってデプロイしました。
(Firebase Cloud Storageも使いたかったので、それに合わせただけです。)
デプロイ手順は以下の記事をご参照ください。
(今回は静的サイトととしてデプロイしています。サーバサイドレンダリングをやりたい場合はFirebase functionsの設定も必要になります。)
####参考
###ブラウザ上でカメラ起動
HTML5のvideo要素を使います。
<template>
....
<video ref="video" id="video" autoplay playsinline></video>
....
</template>
video要素を定義する時、autoplay、playsinline属性を忘れずに入れておきましょう。
これが無いと環境によってはカメラの映像が抽出できなかったりします。
あとはjavascriptからカメラの起動(とvideo要素への投影)をこんな感じで実装します。
methods: {
init: function() {
const video = this.$refs.video; //<template>内で定義したvideo要素
video.width = window.parent.screen.width; //(幅を指定。とりあえず画面幅に合わせる)
//ちなみにとりあえず幅だけ指定しておけば後はカメラの解像度に合わせて高さが勝手に決まります
//カメラの呼び出し
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices.getUserMedia({ video: true }).then(stream => {
//カメラが使用可能なら映像をストリームで取得してvideo要素に設定
video.srcObject = stream;
//撮影開始
video.play();
})
.catch(err => {
//カメラの使用が許可されなかった時
window.alert("このアプリを使用するには、カメラの使用を許可してください。");
});
}
}
}
カメラからの映像出力を止めたい場合は、
video.pause();
カメラの動作そのものを止めたい場合は、navigator.mediaDevices.getUserMediaで取得したstreamに対して
stream.getTracks().forEach(track => track.stop());
を実行します。
###撮影した画像データを取り出してストレージに保存
video要素に映っている画像データを抽出し、Canvas要素に貼り付けます。
今回は、撮影後の画像を加工するため、Fabric.jsを使用してCanvasを制御しています。
Fabric.jsとは、ざっくり言うとHTML5のCanvasの機能を拡張したものです。
Canvas内に図形を描画したり画像を合成したり手書き入力ができるようにしたりなどができるようになります。
Fabric.jsの導入方法はこちら
<template>
....
<canvas ref="canvas" id="canvas"></canvas>
....
</template>
methods: {
capture: function(){
//CanvasのDOMを取得し、video要素の大きさに合わせてサイズを指定
const canvasDom = this.$refs.canvas;
canvasDom.width = video.videoWidth;
canvasDom.height = video.videoHeight;
//Fabricのキャンバスを作成
this.canvas = new fabric.Canvas("canvas", {
isDrawingMode: false,
selection: false
});
const canvas = this.canvas;
//video要素の中身(画像データ)をキャンバスに貼り付け
canvas.contextTop.drawImage(video, 0, 0);
//この後画像を加工する都合上、キャンバスに貼り付けた画像をキャンバスの背景に移す
canvas.setBackgroundImage(canvas.upperCanvasEl.toDataURL(), () => {
canvas.requestRenderAll();
canvas.clearContext(canvas.contextTop);
});
}
}
そしてcanvasに設定した画像データを画像→DataURL→Blob形式に変換してFirebaseのストレージに格納する。
//scriptの戦闘でFirebaseをインポートしておく
import firebase from "~/plugins/firebase";
...
...
methods: {
send: function() {
//キャンバスの設定された画像をDataURLに変換
const canvas = this.canvas;
const dataURL = canvas.toDataURL();
//DataURLからBlob作成
const blob = this.toBlob(dataURL);
//Blob形式で画像をアップロード
const imageData = blob;
//アップロードする画像に適当なキーを付ける
//(実装方法は任意)
const key = this.$util.createUniqueId();
//Firebaseのストレージ呼び出し
const storageRef = firebase.storage().ref(key);
const vue = this;
//ストレージに画像をアップロード
storageRef.put(imageData).then(function(snapshot) {
storageRef
.getDownloadURL()
.then(url => {
//アップロード完了時の処理
//(画像のURLが渡される)
})
.catch(error => {
//ここにエラー発生時の処理
});
});
},
}
参考
- Canvas に描いた画像を png などの形式の Blob に変換する方法: Tender Surrender
- 【v2対応】Nuxt.jsとFirebaseを組み合わせて爆速でWebアプリケーションを構築する
###Now内の顔認識API呼び出し
Firebase Storageにアップロードした画像のURLをaxiosでNowのAPIサーバーに送るだけです。
methods: {
detectFace: function(url,key) {
const baseURL = "NowにデプロイしたAPIのURL(https://~~~.now.sh/~~~";
this.$axios
.post(baseURL, {
url: url
})
.then(response => {
if (response.data.length === 0) {
//顔情報が検出されなかった場合
return;
}
//検出した顔情報を取得
const faceInfoList = response.data;
//以下、
})
.catch(error => {
//顔認識APIに正常に接続できなかった場合
})
.finally(response => {
//撮影した元画像をストレージから削除
const storageRef = firebase.storage().ref(key);
storageRef.delete();
});
},
}
撮影した画像はAPI呼び出し後にFirebase Storageから削除しています。
(残しておいてもしょうがないので)
###鼻毛を生やす
####鼻の穴の位置を算出
Face APIから顔検出結果が返ってくると、以下の座標情報を受け取れます。
(顔検出と顔属性の概念 - Azure Cognitive Servicesより引用)
鼻毛を生やすには鼻の穴の位置を取得する必要がありますが、上記の通りこのままではわかりません。
そこで、以下の手順で大体の鼻の穴の位置を算出します。
- noseLeftAlarOutTipとnoseTipの中点を算出(点Aとする)
- 点AとupperLipTopの中点を算出(点Bとする)
- 点Aと点Bの中点を算出(これを左の鼻の穴の座標とする)
(以下、右の鼻の穴も同様に)
これで、正面を向いた顔であれば鼻の穴の大体の位置がわかります。
参考
####Fabric.jsを使って鼻毛を描画
鼻毛については、Fabric.jsで鼻の穴を始点とするベジェ曲線を複数描画することで表現します。
methods:{
drawNoseHair(nostrils, direction) {
if (!this.settings.nose.isView) {
return;
}
//鼻毛の本数をランダムに決定
const noseHairCount = this.$util.getRandom(3, 5);
const vue = this;
[...Array(noseHairCount)].map(() => {
const initStr = "M " + nostrils.s + " " + nostrils.y;
const line = new fb.fabric.Path(initStr, {
fill: "",
stroke: this.settings.nose.color, //線の色
strokeWidth: 10, //線の太さ
objectCaching: false
});
line.path[0][1] = nostrils.x;
line.path[0][2] = nostrils.y;
//1本の鼻毛を、3~8つのベジェ曲線を組み合わせて表現
const curveCount = vue.$util.getRandom(3, 8);
let position = Object.create(nostrils);
[...Array(curveCount)].map(() => {
const path = ["Q"];
[...Array(2)].map(() => {
//始点から向きをランダムに変化させて伸ばしたベジェ曲線を作成
let xLength = this.$util.getRandom(20, 40);
xLength = direction === "left" ? xLength * -1 : xLength;
let yLength = this.$util.getRandom(0, 50) - 25;
position.x += xLength;
position.y += yLength;
path.push(position.x);
path.push(position.y);
});
//ベジェ曲線の座標情報を追加
line.path.push(path);
});
line.selectable = false;
//キャンバスにベジェ曲線を描画
this.canvas.add(line);
});
},
}
####参考
Quadratic Curve | Fabric.js Demos
###アフロとグラサンを付ける
まずはmountedの中であらかじめ表示させる画像を読み込んでおきましょう。
//scriptの戦闘でfabricをインポートしておく
import fb from "fabric-browseronly";
...
...
mounted: function(){
const vue = this;
//画像ファイルをfabricのオブジェクトとして読み込む
fb.fabric.Image.fromURL("/afro.png", function(img) {
vue.afroImg = img;
});
fb.fabric.Image.fromURL("/sunglass.png", function(img) {
vue.sunglassImg = img;
});
}
そして顔検出情報を取得したらキャンバスに描画します。
(以下はグラサン描画の例です。)
methods: {
drawSunglass: function(eyeLeftOuter, eyeRightOuter) {
const canvas = this.canvas;
//目の両端を結ぶ線分の角度を算出(顔の傾きの分だけ画像を傾けて描画するため)
const degree = this.$util.getDegree(eyeLeftOuter, eyeRightOuter);
//目の両端を結ぶ線分の長さを算出
const eyeWidth = this.$util.getDistance(eyeLeftOuter, eyeRightOuter);
//目の両端を結ぶ線分の中点を算出
const eyeCenter = {
x: (eyeLeftOuter.x + eyeRightOuter.x) / 2,
y: (eyeLeftOuter.y + eyeRightOuter.y) / 2
};
//描画するグラサン画像を用意(mountedであらかじめ読んでたやつ)
const img = this.sunglassImg;
//両目の距離に合わせて画像サイズを適当に調整
img.scaleToWidth(eyeWidth * 1.7);
//画像の色を設定(Fabricのfilterを使って指定した色を画像に混ぜている)
img.filters.push(
new fabric.Image.filters.BlendColor({
color: this.settings.sunglass.color,
mode: "tint",
alpha: 1.0
})
);
img.applyFilters();
//キャンバスに画像を描画
canvas.add(
img.set({
left: eyeCenter.x,
top: eyeCenter.y,
originX: "center",
originY: "center",
angle: degree,
selectable: false
})
);
},
}
アフロの描画もほぼ同様の実装です。
Fabric.jsの使い方については素直に公式のドキュメントを読みましょう。
デモとそのソースもあるのでそっちを読むのもありですね。
####参考
###加工した画像を保存する
キャンバスに描画した画像をダウンロードできるようにします。
methods: {
download: function() {
const userAgent = window.navigator.userAgent.toLowerCase();
if (
userAgent.indexOf("safari") != -1 &&
userAgent.indexOf("chrome") === -1 &&
userAgent.indexOf("edge") === -1
) {
//Safariだけはこの方法でダウンロードできないようなので、処理を中断
return;
}
//キャンバスに描画された内容をDataURLとして取得
const dataurl = this.canvas.toDataURL();
//Blobに変換
const blob = this.toBlob(dataurl);
//a要素にdownload属性を付けたDOMを生成し、クリックイベント発火
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = "hanage";//ファイル名
link.click();
}
}
ただしこの方法、残念ながらSafariではうまくいかないようです。
対応させるにはフロントエンドで色々やるより素直にサーバー側にファイルダウンロードの機能を実装した方が良いでしょう。
####参考
Safariでファイルを強制ダウンロードさせようとしてハマった | Black Everyday Company
#まとめ
最近は便利なライブラリやPaaSなどが次々登場するのでクソアプリ開発の幅が広がりますね。