7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

30代、顔のしわが気になり始めたので仕事中に教えてくれる常駐アプリ「梅おでこ」をつくった

Posted at

image.png

最近おでこのしわが気になります。

仕事中、集中してすごい顔をしているときがあるらしく、額が梅ぼしのよう。ということで、しわしわを検知し教えてくれるアプリをつくってみました。

コンセプトはこんな感じです。

  • 仕事中に裏でこっそり動く
  • Webカメラから表情を読み取り眉間やおでこにしわを検知
  • ポップアップで教えてくれる
  • 検出回数が多い時は嫌がらせのように梅干し画像のウィンドウを立ち上げる

安易に「梅おでこ」という名前をつけました。笑


制作したもの

しわを検出すると作業を邪魔しないようにポップアップがでます。

1回目:「しわ」
image.png

2回目:「しわしわ」
image.png

3回目は、作業を邪魔させていただきます。

3回目:梅干し攻撃

勝手にこちらの画面が立ち上がります。仕事中に立ち上がって、怒られるか怒られないかの微妙なラインのアラートです。

image.png

機能概要

各フレームで眉間のしわと眉毛を上げる(≒おでこにしわが入る)動きを見ています。

カメラFPS: 30.0
フレーム: 100, 検出率: 100.0%, EMA(眉間): 0.015, EMA(眉上げ): 0.178
フレーム: 200, 検出率: 100.0%, EMA(眉間): 0.006, EMA(眉上げ): 0.200
通知回数: 1
フレーム: 300, 検出率: 100.0%, EMA(眉間): 0.000, EMA(眉上げ): 0.754
フレーム: 400, 検出率: 98.0%, EMA(眉間): 0.000, EMA(眉上げ): 0.902
フレーム: 500, 検出率: 100.0%, EMA(眉間): 0.058, EMA(眉上げ): 0.141
通知回数: 2
フレーム: 600, 検出率: 100.0%, EMA(眉間): 0.005, EMA(眉上げ): 0.236
フレーム: 700, 検出率: 100.0%, EMA(眉間): 0.224, EMA(眉上げ): 0.017
通知回数: 3
梅干しタイム!ブラウザを開きます。

EMA は 指数移動平均 (Exponential Moving Average) です。

このようなフローとなっています。

  1. 裏側で Web カメラを起動し MediaPipe Face Landmarker で解析
  2. blendshape(表情スコア)から
    • 眉をひそめる(browDown 系) → 眉間の縦じわ
    • 眉を上げる(browInnerUp / browOuterUp 系) → おでこの横じわ
      を判定
  3. しきい値を超えるとデスクトップ通知を表示
  4. 通知の回数に応じてメッセージを変化させる
  • 1 回目:  「しわです」
  • 2 回目:  「しわしわです」
  • 3 回目:  梅干し画像検索のウィンドウを開く(以降 4〜6 回目も同じサイクル)

技術スタック

カテゴリ 技術・ツール 用途
言語 Python 3.11 メインプログラミング言語
機械学習 MediaPipe (>=0.10.0) 顔検出・表情分析(blendshape)
画像処理 OpenCV (>=4.8.0) カメラ映像の取得・処理
通知 Plyer (>=2.1.0) Windows デスクトップ通知
モデル MediaPipe Face Landmarker 顔のランドマーク検出・表情スコア算出
標準ライブラリ webbrowser ブラウザで梅干し画像検索を開く

MediaPipe の記事は他にも書いているので是非見てください。


しわの検出ロジック

「おでこのしわ」を直接画素レベルで検出しようとすると、
しわの本数や濃さを画像から抽出する必要があり、かなり大変です。

今回は MediaPipe Face Landmarker の blendshapes を使い、次のように割り切っています。

縦じわ(眉間)

  • 眉を下げる動きに対応する blendshape を利用

    • browDownLeft
    • browDownRight
  • これらの平均値を「眉間のしわスコア」として扱う

横じわ(おでこ)

  • 眉を上に上げる動きに対応する blendshape を利用

    • browInnerUp
    • browOuterUpLeft
    • browOuterUpRight
  • これらの平均値を「おでこのしわスコア」として扱う

各スコアは 0.0〜1.0 の範囲で、
値が大きいほど「その表情筋が強く働いている」ことを意味します。

さらに、フレームごとの揺れを抑えるために指数移動平均(EMA)を導入し、
急なノイズではなく「しばらくしわが続いている状態」を捉えやすくしています。

つまり、しわそのものを数えるのではなく、目の位置変化によってしわがあると判定しています。


通知の設計

しわのスコアはリアルタイムに変化しますが、あまり頻繁にポップアップが出ると邪魔になります。
そこで、次のようなルールにしました。

  • 通知の最低間隔: MIN_INTERVAL_SEC 秒(デフォルト 60 秒)

  • EMA 化したスコアがしきい値を超えたときだけ通知候補にする

    • 眉間: FROWN_THRESHOLD(デフォルト 0.40)
    • おでこ: RAISE_THRESHOLD(デフォルト 0.40)
  • 通知のたびに notify_count をインクリメント

  • notify_count % 3 (3で割った余り) で通知内容を分岐

    • 1 回目(余り 1): 「しわです」
    • 2 回目(余り 2): 「しわしわです」
    • 3 回目(余り 0): 梅干し画像検索のみ(通知は出さない)

コード全体

実際に動かしているコードはこちらです。
wrinkle_watcher.py
import sys
import time
from pathlib import Path

import cv2
import mediapipe as mp
from mediapipe.tasks.python import vision
from plyer import notification
import webbrowser

# ====== 設定値 ======
MODEL_PATH = "face_landmarker_v2_with_blendshapes.task"
CAMERA_INDEX = 0
FROWN_THRESHOLD = 0.40      # 眉間にしわ
RAISE_THRESHOLD = 0.40      # 眉を上げすぎ
MIN_INTERVAL_SEC = 60       # 通知の最小間隔(秒)
SMOOTHING_ALPHA = 0.3

# 梅干し表示のトリガー回数
UMEBOSHI_INTERVAL = 3   

# ====== モデルファイルの存在確認 ======
def check_model_file(model_path: str) -> Path:
    """モデルファイルの存在を確認し、絶対パスを返す"""
    # スクリプトのディレクトリを基準にパスを解決
    script_dir = Path(__file__).parent.absolute()
    model_file = script_dir / model_path
    
    if not model_file.exists():
        print("=" * 60)
        print("エラー: モデルファイルが見つかりません")
        print("=" * 60)
        print(f"期待されるパス: {model_file}")
        print()
        print("【解決方法】")
        print("1. 以下のURLからモデルファイルをダウンロードしてください:")
        print("   https://ai.google.dev/edge/mediapipe/solutions/vision/face_landmarker")
        print()
        print("2. 'with blendshapes' のモデルを選択してください")
        print("   (例: face_landmarker_v2_with_blendshapes.task)")
        print()
        print("3. ダウンロードしたファイルを以下のフォルダに保存してください:")
        print(f"   {script_dir}")
        print()
        print("4. ファイル名が異なる場合は、wrinkle_watcher.py の")
        print("   MODEL_PATH を変更してください")
        print("=" * 60)
        sys.exit(1)
    
    return model_file

# モデルファイルの存在確認
model_file_path = check_model_file(MODEL_PATH)
print(f"モデルファイルを読み込みます: {model_file_path}")

# ====== MediaPipe Face Landmarker 初期化 ======
BaseOptions = mp.tasks.BaseOptions
FaceLandmarker = vision.FaceLandmarker
FaceLandmarkerOptions = vision.FaceLandmarkerOptions
VisionRunningMode = vision.RunningMode

try:
    options = FaceLandmarkerOptions(
        base_options=BaseOptions(model_asset_path=str(model_file_path)),
        running_mode=VisionRunningMode.VIDEO,
        num_faces=1,
        output_face_blendshapes=True,
        min_face_detection_confidence=0.5,
        min_face_presence_confidence=0.5,
        min_tracking_confidence=0.5,
    )

    landmarker = FaceLandmarker.create_from_options(options)
    print("モデルファイルの読み込みに成功しました。")
except (RuntimeError, FileNotFoundError, ValueError) as e:
    print("=" * 60)
    print("エラー: モデルファイルの読み込みに失敗しました")
    print("=" * 60)
    print(f"エラー内容: {e}")
    print()
    print("【確認事項】")
    print("1. モデルファイルが正しい場所にありますか?")
    print(f"   {model_file_path}")
    print("2. ファイルが破損していませんか?")
    print("3. ファイル名が正しいですか?")
    print("=" * 60)
    sys.exit(1)
except Exception as e:
    # その他の予期しないエラー
    print("=" * 60)
    print("予期しないエラーが発生しました")
    print("=" * 60)
    print(f"エラー内容: {e}")
    print(f"エラータイプ: {type(e).__name__}")
    print("=" * 60)
    sys.exit(1)


def blendshape_dict(result: vision.FaceLandmarkerResult) -> dict:
    """blendshape を {名前: スコア} dict で返す"""
    if not result.face_blendshapes:
        return {}
    blends = result.face_blendshapes[0]
    return {b.category_name: b.score for b in blends}


def send_notification(title: str, message: str):
    """デスクトップ通知"""
    notification.notify(
        title=title,
        message=message,
        timeout=5,
        app_name="Wrinkle Watcher",
    )


def open_umeboshi_window():
    """梅干し画像検索のウィンドウを開く"""
    # Google画像検索(好きな検索エンジンに変えてOK)
    url = "https://www.google.com/search?tbm=isch&q=%E6%A2%85%E5%B9%B2%E3%81%97"
    webbrowser.open(url)  


def main():
    cap = cv2.VideoCapture(CAMERA_INDEX)
    if not cap.isOpened():
        print("カメラが開けませんでした")
        return

    # カメラのFPSを取得(デフォルトは30fps)
    fps = cap.get(cv2.CAP_PROP_FPS)
    if fps <= 0:
        fps = 30.0  # デフォルト値
    
    last_notify_time = 0.0
    ema_frown = 0.0
    ema_raise = 0.0
    notify_count = 0  # ★ 通知回数カウンタ
    frame_count = 0  # フレームカウンタ(デバッグ用)
    detection_count = 0  # 検出成功回数(デバッグ用)
    
    # タイムスタンプ用の開始時刻
    start_time = time.time()

    print("Wrinkle Watcher 起動中。CTRL+C で終了します。")
    print(f"カメラFPS: {fps:.1f}")

    try:
        while True:
            ok, frame = cap.read()
            if not ok:
                print("フレーム取得に失敗しました。")
                break

            frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            mp_image = mp.Image(
                image_format=mp.ImageFormat.SRGB,
                data=frame_rgb
            )

            # VIDEOモードでは、タイムスタンプは連続的に増加する必要がある
            # 実際の経過時間ベースのタイムスタンプを使用(ミリ秒)
            elapsed_time = time.time() - start_time
            ts_ms = int(elapsed_time * 1000)
            frame_count += 1

            result = landmarker.detect_for_video(mp_image, ts_ms)

            if result.face_blendshapes:
                detection_count += 1
                scores = blendshape_dict(result)

                # 眉間のしわ(縦じわ)
                frown_score = (
                    scores.get("browDownLeft", 0.0)
                    + scores.get("browDownRight", 0.0)
                ) / 2.0

                # 眉を上に上げている(横じわ)
                raise_score = (
                    scores.get("browInnerUp", 0.0)
                    + scores.get("browOuterUpLeft", 0.0)
                    + scores.get("browOuterUpRight", 0.0)
                ) / 3.0

                # 平滑化
                ema_frown = (1 - SMOOTHING_ALPHA) * ema_frown + SMOOTHING_ALPHA * frown_score
                ema_raise = (1 - SMOOTHING_ALPHA) * ema_raise + SMOOTHING_ALPHA * raise_score

                now = time.time()
                can_notify = (now - last_notify_time) > MIN_INTERVAL_SEC

                if can_notify and (ema_frown > FROWN_THRESHOLD or ema_raise > RAISE_THRESHOLD):
                    # 通知回数に応じてメッセージを変更
                    notify_count += 1
                    print(f"通知回数: {notify_count}")
                    
                    # 通知回数を3で割った余りで判定
                    remainder = notify_count % 3
                    
                    if remainder == 1:
                        # 余りが1(1回目、4回目、7回目...): 「しわです」
                        title = "しわです"
                        msg = "眉間に力が入っています。リラックスしましょう。"
                        send_notification(title, msg)
                        last_notify_time = now
                    elif remainder == 2:
                        # 余りが2(2回目、5回目、8回目...): 「しわしわです」
                        title = "しわしわです"
                        msg = "まだ力が入っています。深呼吸してみましょう。"
                        send_notification(title, msg)
                        last_notify_time = now
                    else:  # remainder == 0
                        # 余りが0(3回目、6回目、9回目...): 梅干し検索のみ
                        print("梅干しタイム!ブラウザを開きます。")
                        open_umeboshi_window()
                        last_notify_time = now
            else:
                # 顔が検出されない場合、EMA値を少し減衰させる(検出が止まった場合の対策)
                ema_frown *= 0.95
                ema_raise *= 0.95

            # デバッグ情報(100フレームごとに表示)
            if frame_count % 100 == 0:
                detection_rate = (detection_count / 100) * 100 if frame_count > 0 else 0
                print(f"フレーム: {frame_count}, 検出率: {detection_rate:.1f}%, "
                      f"EMA(眉間): {ema_frown:.3f}, EMA(眉上げ): {ema_raise:.3f}")
                detection_count = 0  # リセット

            # 完全裏で動かすので表示はしない(デバッグ用)
            # cv2.imshow("debug", frame)
            # if cv2.waitKey(1) & 0xFF == ord("q"):
            #     break

            # フレームレートに合わせて待機(30fpsなら約33ms)
            time.sleep(1.0 / fps)

    except KeyboardInterrupt:
        print("\nユーザーにより停止されました。")
    finally:
        cap.release()
        cv2.destroyAllWindows()
        landmarker.close()
        print("終了しました。")


if __name__ == "__main__":
    main()

顔のランドマークを検出するモデルはこちらからダウンロードできます。


おわりに

このアプリの検証で顔がめちゃくちゃ疲れました。

7
1
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
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?