これはなに? 🤔
恋活・婚活マッチングアプリで、異性とマッチングする為の肝となるプロフィール写真。
ただ実際にはプロフ写真が
- 「カメラから遠すぎて顔がよくわからない」
- 「顔が近すぎて暑苦しい」
- 「表情がイマイチ」
と、ちょっと残念なケースも多いようです。
写真ってムズカシイ。。。
そこで。
顔の位置、大きさや表情が最適どうか?を判定して、
自動でモテ写真を撮影するWebアプリを作ってみました。
作ったWebアプリ
※スタート時にカメラへのアクセス許可プロンプトが出ます
使ったあれこれ 🛠️
- face-api
- Next.js
- TypeScript
- ChakraUI
- Firebase Hosting(デモ公開用)
セットアップ 🎒
Next.js と TypeScript
まずはNext.jsをTypeScriptでセットアップ。
npx create-next-app --typescript
詳しくはこちら
Chakra UI
手軽にレイアウトを組めて便利。
インストール
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
FaceAPI
「face-api.js」はブラウザなどで顔検出・顔認識、年齢、性別、表情の予測が行えるライブラリ。TensorFlow/JSを使って実装されています。
オリジナルプロジェクトはこちらですが、今回はメンテが続いている「vladmandic/face-api」を利用させて頂きます。
NodeJSプロジェクトでは、「tensorflow/tfjs-node」を追加でインストールします。
yarn add @vladmandic/face-api @tensorflow/tfjs-node
動作の流れ 💫
至ってシンプルです。
- 開始ボタン押下
- カメラ起動
- 顔を検知
- モテ写真判定
- 満たしていない項目のアドバイスを表示
- 判定OKだったら自動で撮影
- 撮影写真をプレビュー
- 写真を保存
「顔認識」の準備 😀
まず顔認識を行う為に、以下の準備をしていきます。
- TensorFlow
- FaceAPI
- カメラ
- 判定用Canvas
TensorFlow
const setupTensorFlow = async () => {
await faceapi.tf.setBackend('webgl');
await faceapi.tf.enableProdMode();
await faceapi.tf.ENV.set('DEBUG', false);
await faceapi.tf.ready();
}
FaceAPI
トレーニング済みのModelを読み込む
今回がサイズが小さめの tinyFaceDetector
を使用しました。
精度の違いは体感的にそこまで感じませんでした。
const setupModel = async () => {
await faceapi.nets.tinyFaceDetector.load(MODEL_PATH);
await faceapi.nets.ageGenderNet.load(MODEL_PATH);
await faceapi.nets.faceLandmark68Net.load(MODEL_PATH);
await faceapi.nets.faceRecognitionNet.load(MODEL_PATH);
await faceapi.nets.faceExpressionNet.load(MODEL_PATH);
}
カメラ映像を取得
カメラデバイスから取得した映像(MediaStream)をvideoタグに紐づけてプレビューします。
const constraints = {
audio: false,
video: true,
};
let stream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = stream;
Canvas
認識した顔情報の大きさや位置などの判定用に使用するcavasです。
Videoタグとサイズを合わせる形で設定していきます。
const track = stream.getVideoTracks()[0];
const settings = track.getSettings();
// Canvas Settings
canvas.width = settings.width ? settings.width : 0
canvas.height = settings.height ? settings.height : 0
canvas.style.width = "100%"
canvas.style.height = "100%"
処理開始
videoタグのonloadeddata()で、データのロード完了時に認識処理をスタートさせます。
そのままだと、Canvasへの出力が左右逆になってしまうので反転させておきます。
そして顔情報を処理するためのdetectHandler()を呼び出します。
video.onloadeddata = async () => {
video.play();
const ctx = canvas.getContext('2d');
// Invert Canvas
ctx?.scale(-1,1);
ctx?.translate(-canvas.width, 0);
await detectHandler()
};
detectHandler()
ここでvideoタグから得たストリームデータを取得して、顔認識処理を実行します。
requestAnimationFrame使い、再帰的に呼び出して繰り返し実行していきます。
requestAnimationFrame()
は、画面が描画される度に呼ばれる処理で、呼び出されるタイミングが固定値であるsetInterval()
を使うよりもより滑らかな体感を得ることが出来ます。
const video = videoRef.current as HTMLVideoElement
const detectedFace = await faceapi
.detectSingleFace(video, detectorOptions)
.withFaceLandmarks()
.withFaceExpressions()
.withAgeAndGender()
// Check Detected Face
checkFace(detectedFace)
// For Performance
requestAnimationFrame(
() => detectHandler()
);
モテ判定 🤖
判定基準は、マッチングアプリ「with(ウィズ)」を参考にさせて頂き
プロフィール写真のベストプラクティス
- 写真に顔がキチンと写っているか
- 基準
- 真ん中に来ているか?
- 大きさが適切か?
- 基準
と
- 表情がイケてるか?
をチェック。
後ついでなので、顔から推定される年齢も出してみます。
まずは顔が真ん中にあるか?
認識した顔の位置がcanvasの中央にあるか、を判定します。
上下左右には少し遊びをつけて判定しています。
検知した顔の矩形情報
const detectedFace = await faceapi
.detectSingleFace(video, detectorOptions)
.withFaceLandmarks()
const faceFrame = detectedFace.detection.box
これを元に、顔位置のセンター判定を行います
// Center of frame
const frameCenter:{x: number, y: number} = {x: frame.w/2, y: frame.h/2}
// margin
const cordinator:{w: number, h: number} = { w: 200, h: 200 }
// Allowable U D L R
const toleranceRange: {
left: number,
right: number,
top: number,
bottom: number,
} = {
left: frameCenter.x - cordinator.w,
right: frameCenter.x + cordinator.w,
top: frameCenter.y - cordinator.h,
bottom: frameCenter.y + cordinator.h,
}
// Center of face
const centerOfFace:{x: number, y: number} = {
x: faceFrame.x + (faceFrame.width/2),
y: faceFrame.y + (faceFrame.height/2)
}
// 判定
// horizontal
let horizontal = false
if( toleranceRange.left < centerOfFace.x
&& centerOfFace.x < toleranceRange.right ){
horizontal = true
}
// vertical
let vertical = false
if( toleranceRange.top < centerOfFace.y
&& centerOfFace.y < toleranceRange.bottom ){
vertical = true
}
const isJustRight = (horizontal && vertical)
顔の大きさが適切か?
フレームに対して丁度良いサイズに収まっているか、を判定します。
検知した顔の矩形情報
const detectedFace = await faceapi
.detectSingleFace(video, detectorOptions)
.withFaceLandmarks()
const faceFrame = detectedFace.detection.box
顔の大きさ判定
今回は枠に対して顔の占める割合が30%〜50%に収まっていればOKとしました。
const frameArea = frame.w * frame.h
const faceArea = faceFrame.width * faceFrame.height
// 30% to 50%
const lowRatio = 0.2
const highRatio = 0.3
const ratio = faceArea / frameArea
let isSufficient = (lowRatio <= ratio && ratio <= highRatio)
表情がイケてるか?
ここではFaceAPIが予測した表情が「happy」であるかどうか、を確認していきます。
表情の認識
const detectedFace = await faceapi
.detectSingleFace(video, detectorOptions)
.withFaceExpressions()
const expressions = detectedFace.expressions
FaceAPIが返す表情のタイプ はこちら👇
- angry
- disgusted
- fearful
- happy
- neutral
- sad
- surprised
各Keyの予測値はその表情の確率を表します。
export type FaceExpression = {
neutral: number
happy: number
sad: number
angry: number
fearful: number
disgusted: number
surprised: number
}
予測値が一番高い表情が「Happy」であればOKとします。
export type FaceExp = {
expression: string;
prediction: number;
}
// expression <- FaceExpression
const faceExps: FaceExp[] = []
for (const [key, value] of Object.entries(expression)) {
const faceExp: FaceExp = {
expression: key,
prediction: value
}
faceExps.push(faceExp)
}
const sorted = faceExps.sort( (prev, current) => {
return (prev.prediction > current.prediction) ? -1 : 1
})
console.log(sorted[0].expression)
顔から推定される年齢
FaceAPIが推定する年齢を取得しています。
推定年齢
const detectedFace = await faceapi
.detectSingleFace(video, detectorOptions)
.withAgeAndGender()
const age = detectedFace.age
これは年齢をそのまま数値で取得出来ます。
View 👀
今回のデモではChakraUIを利用しています。
おかげで直感的にレイアウト出来ました。
MoteCamComponent
メインView
- 撮影プレビュー用 videoタグ
- 顔認識判定用 canvasタグ
- 撮影ボタン
<>
<VStack my={4}>
{
(moteCam.isStarted === true && moteCam.isReady === false) &&
<Spinner
thickness='4px'
speed='0.65s'
emptyColor='gray.200'
color='blue.500'
size='xl'
my={8}
/>
}
<Box my={4} id="video-frame" className={styles.videoFrame} display={ moteCam.isReady === true ? 'block' : 'none' }>
<video ref={moteCam.videoRef} playsInline className={styles.videoWebcam}></video>
<canvas ref={moteCam.canvasRef} id="canvas" className={styles.drawCanvas} />
</Box>
<Button my={4} mx='auto' size='lg' w='80%' colorScheme='blue' onClick={moteCam.startAndStop}>
{ moteCam.isStarted ? "Stop" : "Start" }
</Button>
{ moteCam.isStarted && <MoteCamMessage {...moteCam.moteCamAdvice} />}
</VStack>
</>
撮影した写真を表示するModal
- Imageタグ
- 保存ボタン
<Modal isOpen={moteCam.isTakenPhoto} onClose={moteCam.dismissTakenPhoto}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{localizedStrings.PHOTO_COMPLETION_TITLE}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Image ref={moteCam.photoRef} id='photo' alt="photo"></Image>
</ModalBody>
<ModalFooter>
<Button my={4} colorScheme='blue' onClick={moteCam.downloadPhoto}>写真を保存</Button>
</ModalFooter>
</ModalContent>
</Modal>
撮影時のアドバイス 📢
モテモテ写真を撮影するために
🤖「もう少し右」「カメラに顔を近づけて」
といったメッセージをアドバイスします。
画面上にテキストを表示するだけでは、目線がカメラから離れてしまいます。
そこで音声でもアドバイスを行うことで、カメラ目線で写真が撮影出来るようにします。
テキストでのアドバイス
MoteCamMessage
アドバイスのテキストメッセージを表示します
<Box my={8}>
<VStack mx={4}>
<HStack>
<Text>{expression.message}</Text>
</HStack>
<HStack>
<Text>{age.message}</Text>
</HStack>
<HStack>
<Text>{faceSize.message}</Text>
</HStack>
<HStack>
<Text>{facePosition.message}</Text>
</HStack>
</VStack>
</Box>
音声をつかったアドバイス
useSpeech
Web Speech API の SpeechSynthesis
を使ってテキストメッセージを音声再生します。
メッセージを読み上げる
const speakAdvice = ( message: string ) => {
if( message !== 'undefined' && typeof speechSynthesis !== 'undefined'
&& speechSynthesis.speaking === false ){
speechSynthesis.cancel(); // for chrome bugs
const utterance = new SpeechSynthesisUtterance();
utterance.text = message;
utterance.lang = languageCode;
speechSynthesis.speak(utterance)
}
}
💡 iOS Safari ではしゃべり始めにユーザ操作が必要
iOS Safariでは、ユーザーのインタラクションがないと音声の再生が出来ません。
例えば「ページのロード完了」をトリガーにして、いきなり自動再生というのはNG。
なので
- ユーザーに「スタートボタンを押す」などのアクションをしてもらう
- その後、一度APIを呼んで何か喋らせる(「開始します」など)
このステップを踏むことで、その後は自動再生を連続して行うことが出来ます。
モテ写真の撮影 📸
顔の位置、大きさ、表情の条件が全て整うと自動で撮影が実行されます。
流れ
- videoタグに流していたMediaStream を使って、Canvasに画像を描画する
- 描画データをImageタグにsrcに指定してプレビューする
const stream: MediaStream = video.srcObject as MediaStream
if( stream === null ) return
const track = stream.getVideoTracks()[0];
const settings = track.getSettings();
const width = settings.width ? settings.width : 0
const height = settings.height ? settings.height : 0
canvasForDraw.width = width
canvasForDraw.height = height
const ctx: CanvasRenderingContext2D = canvasForDraw.getContext('2d')!
// Inversion
ctx.scale(-1,1);
ctx.translate(-width, 0);
ctx.drawImage(video, 0, 0, width, height);
photo.src = canvasForDraw.toDataURL('image/png');
撮影写真の保存
撮影写真は画像部分を長押ししても保存可能ですが、ボタンアクションでのダウンロード方法も用意しておきます。
const downloadPhoto = () => {
if( photoRef.current ){
const a = document.createElement("a");
const photo = photoRef.current as HTMLImageElement
document.body.appendChild(a);
a.download = 'best_shot.png';
a.href = photo.src;
a.click();
a.remove();
}
}
デモサイトの公開 🙌
今回は動作デモ用にFirebase Hostingを使いました。
静的にエクスポートしたものをアップロードするだけなので、お手軽に公開出来て良かったです。
firebase-toolsをインストール
npm install -g firebase-tools
認証後、トークン取得
firebase login:ci
初期化
firebase init
公開ディレクトリ設定では、out
ディレクトリを設定。
What do you want to use as your public directory?
Firebase Hosting の詳細はこちら
https://firebase.google.com/docs/hosting
ビルド&エクスポート&デプロイ
npx next build && npx next export && firebase serve --only hosting --token $FIREBASE_TOKEN
Demo
おわりに ☕
ソースコード全体はこちら
今回、Firebase Hostingを使うにあたって静的にエクスポートしてアップロードする必要がありました。
そのため、デモ版の方はローカライズ周りなどの実装が若干異なります。
多少なり、プロフ写真の参考になればうれしいです🙌