こんにちは。
子育て奮闘中のエンジニア、遠藤です。

今日は「顔検出を使って、子供に歯磨きを楽しんでもらおう!」と試みたものの、様々な失敗を経験したお話をしたいと思います。

育児と仕事の両立についてで紹介した娘も、早いもので、もう2歳になりました。

娘の歯は8ヶ月頃から生え始め、少しずつ歯磨きの習慣をつけています。しかし、1歳半頃から始まったイヤイヤ期は、2歳前後でさらに激しくなり、泣き叫ぶことも増えました。そんな娘でも、できれば楽しく歯磨きしてほしいと考え、色々と試行錯誤してみました。

市販の歯磨き支援アプリは多くが大きい子供向けで、まだ絵本もあまり読めない1歳児には少し早いように感じました。そこで、自分で何か作ってみることにしました。

娘は鏡を見るのが好きなので、自分の顔を見ながら「アー」と「イー」の口をすることで、うまくできたとフィードバックを受け取れるアプリが良いのではないかと考えました。

そこで、Androidで顔検出ができるライブラリを調べたところ、「顔検出API」と「顔メッシュ検出API」の2つがあることが分かりました。ちょうどML Kitを試してみたかったので、これを使って顔検出に挑戦することにしました。

公式が提供しているサンプルアプリをインストールして、実際に試してみます。
https://github.com/googlesamples/mlkit/tree/master/android/vision-quickstart

顔検出API

https://developers.google.com/ml-kit/vision/face-detection?hl=ja

画像内の顔を検出し、主な顔の特徴を識別して、検出された顔の輪郭を取得できます。

説明にある通り、こちらは、顔のパーツや輪郭の座標を返してくれるものになります。
公開されているサンプルを起動すると、以下のようになります。

ただ、こちらでは、口を開けた時に、唇の動きに追従できませんでした。

これでは、「あ」や「い」の口の形を判別できなさそうです。

顔メッシュ検出API

https://developers.google.com/ml-kit/vision/face-mesh-detection?hl=ja

ML Kit の顔メッシュ検出 API を使用すると、自撮り写真のような画像用に 468 個の 3D ポイントからなる高精度メッシュをリアルタイムで生成できます。

顔メッシュ検出APIはまだベータ版の扱いですが、上記の説明にある通り、顔の 3D ポイントの座標を返しくれるものになります。

こちらは、口の動きに対応できるのでしょうか?

バッチリできていますね。

座標を取得して描画できそうなことは分かりましたので、この座標の中から口の部分に該当する箇所を探します。
コードを眺めていると、それらしいパラメータがあります。

https://github.com/googlesamples/mlkit/blob/master/android/vision-quickstart/app/src/main/java/com/google/mlkit/vision/demo/java/facemeshdetector/FaceMeshGraphic.java#L57-L70

そして、それらしい描画処理も見つかりました。

https://github.com/googlesamples/mlkit/blob/master/android/vision-quickstart/app/src/main/java/com/google/mlkit/vision/demo/java/facemeshdetector/FaceMeshGraphic.java#L119-L148

この辺りをいじって、唇の部分だけ描画してみるようにしてみます。
元のコードをコメントアウトし、以下の4つの座標を描画してみます。

  • FaceMesh.UPPER_LIP_BOTTOM
  • FaceMesh.UPPER_LIP_TOP
  • FaceMesh.LOWER_LIP_BOTTOM
  • FaceMesh.LOWER_LIP_TOP
唇の塗りつぶしコード
    // Draw face mesh points
//    for (FaceMeshPoint point : points) {
//      updatePaintColorByZValue(
//          positionPaint,
//          canvas,
//          /* visualizeZ= */ true,
//          /* rescaleZForVisualization= */ true,
//          point.getPosition().getZ(),
//          zMin,
//          zMax);
//      canvas.drawCircle(
//          translateX(point.getPosition().getX()),
//          translateY(point.getPosition().getY()),
//          FACE_POSITION_RADIUS,
//          positionPaint);
//    }

//    if (useCase == FaceMeshDetectorOptions.FACE_MESH) {
//      // Draw face mesh triangles
//      for (Triangle<FaceMeshPoint> triangle : triangles) {
//        List<FaceMeshPoint> faceMeshPoints = triangle.getAllPoints();
//        PointF3D point1 = faceMeshPoints.get(0).getPosition();
//        PointF3D point2 = faceMeshPoints.get(1).getPosition();
//        PointF3D point3 = faceMeshPoints.get(2).getPosition();
//
//        drawLine(canvas, point1, point2);
//        drawLine(canvas, point2, point3);
//        drawLine(canvas, point3, point1);
//      }
//    }

    List<FaceMeshPoint> upperLipBottoms = faceMesh.getPoints(FaceMesh.UPPER_LIP_BOTTOM);
    List<FaceMeshPoint> upperLipTop = faceMesh.getPoints(FaceMesh.UPPER_LIP_TOP);
    List<FaceMeshPoint> lowerLipBottom = faceMesh.getPoints(FaceMesh.LOWER_LIP_BOTTOM);
    List<FaceMeshPoint> lowerLipTop = faceMesh.getPoints(FaceMesh.LOWER_LIP_TOP);
    Paint paint = new Paint();
    Path path = new Path();
    paint.setStyle(Style.FILL_AND_STROKE);
    paint.setStrokeWidth(2f);
    paint.setColor(Color.RED);
    paint.setAntiAlias(true);
    FaceMeshPoint start = upperLipBottoms.get(0);
    path.moveTo(
            translateX(start.getPosition().getX()),
            translateY(start.getPosition().getY()));

    for (FaceMeshPoint point : upperLipBottoms) {
      path.lineTo(
              translateX(point.getPosition().getX()),
              translateY(point.getPosition().getY())
      );
    }
    for (int i = upperLipTop.size() - 1; i >= 0; i--) {
      FaceMeshPoint point = upperLipTop.get(i);
      path.lineTo(
              translateX(point.getPosition().getX()),
              translateY(point.getPosition().getY())
      );
    }
    start = lowerLipBottom.get(0);
    path.moveTo(
            translateX(start.getPosition().getX()),
            translateY(start.getPosition().getY()));

    for (FaceMeshPoint point : lowerLipBottom) {
      // スタート地点を移動
      path.lineTo(
              translateX(point.getPosition().getX()),
              translateY(point.getPosition().getY())
      );
    }
    for (int i = lowerLipTop.size() - 1; i >= 0; i--) {
      FaceMeshPoint point = lowerLipTop.get(i);
      // スタート地点を移動
      path.lineTo(
              translateX(point.getPosition().getX()),
              translateY(point.getPosition().getY())
      );

    }

    path.close();
    canvas.drawPath(path, paint);

このコードで、実行してみた結果がこちら。

どうでしょう。(分かりやすく塗りつぶしてみました)
綺麗に唇周りの座標を取得できていそうなことがわかりました。

口の形を判定する

取得できる座標は、それぞれ以下の値を取得できます。

  • FACE_OVAL: 顔の輪郭
  • LEFT_EYEBROW_TOP: 左眉の上
  • LEFT_EYEBROW_BOTTOM: 左眉の下
  • RIGHT_EYEBROW_TOP: 右眉の上
  • RIGHT_EYEBROW_BOTTOM: 右眉の下
  • LEFT_EYE: 左目
  • RIGHT_EYE: 右目
  • UPPER_LIP_TOP: 上唇の上部
  • UPPER_LIP_BOTTOM: 上唇の下部
  • LOWER_LIP_TOP: 下唇の上部
  • LOWER_LIP_BOTTOM: 下唇の下部
  • NOSE_BRIDGE: 鼻筋

今回は、口の形を判定したいので、以下の4つの値を使ってみます。

  • UPPER_LIP_BOTTOM: 上唇の下部
  • LOWER_LIP_TOP: 下唇の上部

また、顔とカメラの距離にもよって、値の大小が変わってしまうので、顔のサイズも一緒に取得します。

図にすると、以下になります。

ここから「あ」と「い」の判定をしてみます。
実際にデバッグしながら自分の顔を変えつつ、「あ」「い」の時に、どのような値になるかを判断しました。

基本的には、以下の比較を行いました。

顔の横幅:口の横幅
顔の縦幅:口の縦幅(上唇の下部と下唇の上部)

判定条件としては、以下の設定をした時に、それっぽい判断ができました。

    if (lipHeight / faceHeight > 0.12 ) {
      // あ, お
      if (lipWidth / faceWidth > 0.25 ) {
        text = "あ";
      } else {
        text = "お";
      }
    } else if (lipHeight / faceHeight > 0.09) {
      // え
      if (lipWidth / faceWidth > 0.25 ) {
        text = "え";
      } else {
        text = "お";
      }
    } else if (lipHeight / faceHeight > 0.05) {
      // い
      if (lipWidth / faceWidth > 0.25 ) {
        text = "い";
      } else {
        text = "お";
      }
    } else if (lipHeight / faceHeight > 0.02) {
      if (lipWidth / faceWidth < 0.20 ) {
        text = "う";
      }
    }

日本語で書くと、こんな感じになります。

  • 「あ」:口の高さと顔の高さの比が0.12より大きく、かつ口の幅と顔の幅の比が0.25より大きい場合
  • 「い」:口の高さと顔の高さの比が0.05より大きく、かつ0.09以下で、口の幅と顔の幅の比が0.25より大きい場合
  • 「う」:口の高さと顔の高さの比が0.02より大きく、かつ0.05以下で、口の幅と顔の幅の比が0.20より小さい場合
  • 「え」:口の高さと顔の高さの比が0.09より大きく、かつ0.12以下で、口の幅と顔の幅の比が0.25より大きい場合
  • 「お」:
    • 口の高さと顔の高さの比が0.12より大きく、かつ口の幅と顔の幅の比が0.25以下の場合
    • または口の高さと顔の高さの比が0.09より大きく、かつ0.12以下で、口の幅と顔の幅の比が0.25以下の場合
    • または口の高さと顔の高さの比が0.05より大きく、かつ0.09以下で、口の幅と顔の幅の比が0.25以下の場合

フィードバック

ここまできたら、あとは「あ」と「い」 の口の形にしてもらい、上手くできていたら、画面がキラキラとかすれば良いかなと思います。(安直)

「あ」と「い」の口は、イラストやから素材をもらってきました。
画面の右上に表示しておき、今、どちらの口をすれば良いか分かるようにしています。

パーティクル

キラキラは、パーティクルのようなものを canvas 上に描画しています。
判定した口の形と指示が一致したら、 Paricle View が表示されます。

-----2024-05-30-22.44.20

その他機能

しっかり、カメラボタンも配置し、子供が可愛い顔をした瞬間を逃しません。
今思うと、これはカメラより動画の方が良かったなぁと思いました。

これで完成?

さて。
実装したい機能はできました。
これで完成のはずだったのですが、このアプリにはいくつもの失敗が含まれています。

失敗1

大きな問題の一つが、開発に時間をかけすぎて、子供が大きくなってしまったことです。
もう、鏡を見てキャッキャしてくれる1歳の娘はいませんでした。

アプリを起動しても、娘は見向きもしてくれません。
納期って大事なんだな、ということを改めて感じました。

失敗2

顔のサイズが違う。
それはまぁそうなんですが、顔のサイズが違うので、自分の顔の間隔で「あ」「い」がそれなりに判定できるようになっても、子供の顔では判定がイマイチでした。。

失敗3

口の形が分かりやすいかな?と思って、唇を赤く塗ってみたのですが、「口が赤くなっちゃったー!」と大泣きしてしまいました。

失敗4

そしてこちらが大問題なのですが、歯磨きをする時って、歯ブラシを持つんだよって知っていましたか?
その手が、画面に映りこんでしまい、口の形がうまく判定できません。

終わりに

コンセプト自体は面白いと思ったのですが、時間が足りず、子供の成長により時間切れになってしまいました。
口のサイズだけでなく、他の要素も見ながら判定できれば、もう少し精確に判定もできたかもしれません。

失敗したアプリではありますが、 git に公開しています。
https://github.com/milldea/baby-tooth-brush-play-sample
歯磨きではなくても、何か面白いことに使えそうな気がしています。
弊社には子育て中の社員が何名かいますので、いい感じで仕上げてもらいたいです...(願望)

さて、歯磨きおてつだいアプリは完成できなかったのですが、その間子供の歯磨きを助けてくれた商品がこちら。
(プロモーションではありません。)

https://www.segatoys.co.jp/anpan/dekitaseries/

おやこでシャカシャカ アンパンマンピカピカはみがきミラー

とっても楽しく歯磨きできるようになりましたので、お困りの方はぜひお試しください(あれ?なんの話だっけ...)

他にも、もう少し大きな子になると、こんなアプリも使えそうでした!
https://okuchi-iku.lion.co.jp/

ごはんを食べたら、さあ冒険だ。まほうハブラシ
ゲーム感覚で楽しみながら、
仕上げみがきの卒業をサポート。

これは楽しそう。
その日が来るのを楽しみに待ちつつ、今はもう少し子供の歯磨きに奮闘します。