5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【FaceAPI + Next.js】マッチングアプリで使える!モテ写真を自動撮影する📸

Posted at

これはなに? 🤔

恋活・婚活マッチングアプリで、異性とマッチングする為の肝となるプロフィール写真。

ただ実際にはプロフ写真が

  • 「カメラから遠すぎて顔がよくわからない」
  • 「顔が近すぎて暑苦しい」
  • 「表情がイマイチ」

と、ちょっと残念なケースも多いようです。
写真ってムズカシイ。。。

そこで。

顔の位置大きさ表情が最適どうか?を判定して、
自動でモテ写真を撮影するWebアプリを作ってみました。

作ったWebアプリ

※スタート時にカメラへのアクセス許可プロンプトが出ます

motecam-preview1.png

使ったあれこれ 🛠️

  • 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

動作の流れ 💫

至ってシンプルです。

  1. 開始ボタン押下
  2. カメラ起動
  3. 顔を検知
  4. モテ写真判定
    1. 満たしていない項目のアドバイスを表示
    2. 判定OKだったら自動で撮影
  5. 撮影写真をプレビュー
  6. 写真を保存

「顔認識」の準備 😀

まず顔認識を行う為に、以下の準備をしていきます。

  • 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

motecam-main-view.png

メイン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を使うにあたって静的にエクスポートしてアップロードする必要がありました。
そのため、デモ版の方はローカライズ周りなどの実装が若干異なります。

多少なり、プロフ写真の参考になればうれしいです🙌

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?