🚶

MediaPipeとストリートビューAPIでバーチャル散歩して痩せたかった

2023/12/10に公開

作ったもの

https://youtu.be/lOcITplfB1o

※プライバシーのために顔をDALLEで作ったイケメンに差し替えております

これはなに?

  • Webアプリ
  • その場で足踏みをするとストリートビュー内で散歩ができる
  • 手を上げると左右に曲がることもできる
  • 移動距離/歩数/消費カロリー(超概算)も表示される

アプリの公開をしたかったけど、APIの無料枠を超えてお金かかるのを避けたかったので公開してません
コードは記事の最後に公開するので気になる人は自分で発行したAPIキーで動かしていただければと

なんでつくったの?

痩せたかった

コロナが〜とかでもなくただひたすら運動せずにラーメンばっか食べてたら太った
でも基本的に家から出たくないのでジムとか無理だし狭小住宅なのでサイクリングマシンとかも置けない
フィットボクシングやってたけどそれすら始めるのめんどくさくてやらなくなった

MediaPipeを使いたかった

MediaPipeという画像検出とかポーズ検出ができるライブラリを知ったので使ってみたかった

似たようなやつ見ていいなって思ってた

https://omocoro.jp/kiji/230063/ みたいなの作りたかった
でもサイクリングマシンもないしマイコンもないし何もなくてもできるものを作りたかった

記事の対象読者

  • MediaPipeが気になってる人
  • ストリートビューのAPI使いたい人
  • React/TypeScriptを使う人

書かないこと/やらないこと

  • UIの話(蛇足なので)

おおまかな処理の流れ

  • ユーザがその場で足踏みをしたり手を上げたりする
  • MediaPipeでポーズ検出をして、歩いていることや手を上げていることを判定する
  • ストリートビューのAPIを呼び出し、前進させたり視点を回転させたりする

MediaPipeの実装

MediaPipeとは?

MediaPipeは、機械学習を利用したリアルタイムのビジョンとオーディオ処理のためのGoogleのオープンソースフレームワークです

今回はポーズ検出に使います

必要なライブラリを取得

MediaPipeのライブラリは2023年に大幅な変更をしています
実装方法について検索するとレガシーな方のライブラリの使い方の記事が結構出てきたりするので注意してください
詳しくはこちら
@mediapipe/poseを使っている実装がレガシーな実装で、@mediapipe/tasks-visionを使う実装が2023/12現在の最新です

yarn add @mediapipe/tasks-vision

MediaPipe Poseとは?

https://developers.google.com/mediapipe/solutions/vision/pose_landmarker

リアルタイムで人間のポーズを検出し、体の主要な位置を特定することができる機能です

具体的に言うと、以下の33箇所の体のパーツの位置を3次元空間内のX軸(横方向)/Y軸(縦方向)/Z軸(奥行き方向)の座標で取得できます

landmarks

カスタムフックの作成

https://developers.google.com/mediapipe/solutions/vision/pose_landmarker/web_js
を参考にMediaPipe Poseを簡単に扱えるカスタムフックを作ります

useMediaPipeという名前でHooksを作ります

import { useEffect, useState, RefObject } from "react";
import Webcam from "react-webcam";
import {
  FilesetResolver,
  NormalizedLandmark,
  PoseLandmarker,
} from "@mediapipe/tasks-vision";

export const useMediaPipe = (webcamRef: RefObject<Webcam>) => {
  const [landmarks, setLandmarks] = useState<NormalizedLandmark[]>();

  useEffect(() => {
    const setupPoseLandmarker = async () => {
      const vision = await FilesetResolver.forVisionTasks(
        "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"
      );

      const poseLandmarker = await PoseLandmarker.createFromOptions(vision, {
        baseOptions: {
	  // モデルは3種類から選べる、ここでは一番軽いやつを使っている
          modelAssetPath:
            "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task",
	  // GPUにしないとなんかガタガタする
          delegate: "GPU", 
        },
        runningMode: "VIDEO",
      });

      poseLandmarker.setOptions({
        runningMode: "VIDEO",
      });

      let lastVideoTime = -1;
      const renderLoop = () => {
        const video = webcamRef.current?.video;
        if (video && video.currentTime !== lastVideoTime) {
          const timestamp = video.currentTime * 1000;
          poseLandmarker.detectForVideo(video, timestamp, (results) => {
            setLandmarks(results.landmarks[0]);
            lastVideoTime = video.currentTime;
          });
        }
        requestAnimationFrame(() => renderLoop());
      };

      renderLoop();
    };

    setupPoseLandmarker();
  }, [webcamRef]);

  return { landmarks };
};

公式に

Calls to the Pose Landmarker detect() and detectForVideo() methods run synchronously and block the user interpose thread. If you detect poses in video frames from a device's camera, each detection blocks the main thread. You can prevent this by implementing web workers to run the detect() and detectForVideo() methods on another thread.

とあるので、本当は別スレッドでの実装をするほうがいいらしのですが、なかなかうまくいかなかったので今回は実施していません

このカスタムフックにカメラのRefを与えるとlandmarksという名前で体のパーツの座標を返してくれます
landmarksはフレームごとに更新されます

例えばこんな感じで使います

const { landmarks } = useMediaPipe(webcamRef);

...

// 右肩の位置
const rightShoulder = landmarks[12] //先程の図の33箇所(0〜32)

// 右肩の座標を出力
console.log(rightShoulder.x, rightShoulder,y)

動作の判定

これもまたカスタムフックを作って動作を判定します

usePoseというHooksを作ります

右手/左手/両手を上げたことを検出する

肩よりも手の位置が上になったら手が上がったと判定します

const rightHandsUp = landmarks[16].y < landmarks[12].y;
const leftHandsUp = landmarks[15].y < landmarks[11].y;
const bothHandsUp = rightHandsUp && leftHandsUp;

setRightHandsUp(rightHandsUp);
setLeftHandsUp(leftHandsUp);
setBothHandsUp(bothHandsUp);

歩行の検出

歩行を判定するためには、単なるパーツ同士の位置の相関関係では不十分です
このため、足と腰の初期位置を取得し、現在位置の比較を基に判定を行います

  1. まず、全身がカメラに映る状態で準備が整ったら両手を上げてもらい、その姿勢を初期位置として記録します
  2. 初期位置における足(かかと)と腰の高さの差の1/8を閾値とし、足がこの閾値より上にある場合は足を上げていると判定します
  3. 足を上げてから下ろす動作を1歩とカウントします
  4. 歩数のカウントは交互に行われ、右足を上げ下ろした後に左足を上げ下ろすと歩数が増加します
  5. 歩数が4の倍数に達すると、歩いたと判定します

このロジックを用いることで、歩行の検出を目指します


まず、両手を上げたときに初期位置の足と腰の高さの差の1/8以上を閾値の高さとして保存します


const stepThresholdRatio = 1 / 8;

...

if (bothHandsUp && !rightFootUpThreshold && !leftFootUpThreshold) {
  setRightFootUpThreshold(
  landmarks[30].y -
    (landmarks[30].y - landmarks[26].y) * stepThresholdRatio
  );
  setLeftFootUpThreshold(
  landmarks[29].y -
    (landmarks[29].y - landmarks[25].y) * stepThresholdRatio
  );
}

直前の足の状態を保存し、閾値の高さの判定と合わせて歩いていることを判定します

if (
  (beforeWalkPoseType === "none" || beforeWalkPoseType === "leftDown") &&
  landmarks[30].y < rightFootUpThreshold
) {
  setBeforeWalkPoseType("rightUp");
}

if (
  beforeWalkPoseType === "rightUp" &&
  landmarks[30].y > rightFootUpThreshold
) {
  setBeforeWalkPoseType("rightDown");
  setWalkCount(walkCount + 1);
}

if (
  (beforeWalkPoseType === "none" || beforeWalkPoseType === "rightDown") &&
  landmarks[29].y < leftFootUpThreshold
) {
  setBeforeWalkPoseType("leftUp");
}

if (
  beforeWalkPoseType === "leftUp" &&
  landmarks[29].y > leftFootUpThreshold
) {
  setBeforeWalkPoseType("leftDown");
  setWalkCount(walkCount + 1);
}

歩数が4の倍数になったときに歩いたと判定します

useEffect(() => {
  if (walkCount !== 0 && walkCount % 4 === 0) {
    setWalk(true);
  } else {
    setWalk(false);
  }
}, [walkCount]);

このカスタムフックにuseMediaPipeから取得したlandmarksを与えると動作を返してくれます

例えばこんな感じで使います

const { walk, leftHandsUp, rightHandsUp } =
    usePose(landmarks);
...

useEffect(() => {
  if (walk) {
    // 歩いたときに行いたい処理
  }
}, [walk]);

ストリートビューAPIの実装

カスタムフックの作成

GoogleMapAPIのカスタムフックとか探せばありそうですが、公式っぽいものはなさそうなので自作しました

useStreetViewというHooksを作ります

視点を回転させる処理

手を上げたときに動かす視点を回転させる処理です
具体的にはpov(視点)のheading(向き)を変更します
requestAnimationFrameを使ってフレームごとにちょっとずつ回転させるようことでぬるっと回るようにしています

// 指定された角度だけカメラを回転させる関数
  const turn = useCallback(
    (angle: number) => {
      if (streetViewRef.current && !rotating) {
        setRotating(true);
        let pov = streetViewRef.current.getPov();
        const targetHeading = pov.heading! + angle;

        const rotate = () => {
          if (streetViewRef.current) {
            pov = streetViewRef.current.getPov();
            const headingDifference = targetHeading - pov.heading!;

            if (Math.abs(headingDifference) > 0.5) {
              pov.heading! +=
                (headingDifference / Math.abs(headingDifference)) * 0.5;
              streetViewRef.current.setPov(pov);
              requestAnimationFrame(rotate);
            } else {
              streetViewRef.current.setPov({ ...pov, heading: targetHeading }); // 目標に到達したら正確に角度を設定
              setRotating(false);
            }
          }
        };

        requestAnimationFrame(rotate);
      }
    },
    [rotating]
  );

  // 左にちょっと回転する関数
  const turnLeft = useCallback(() => {
    turn(-1);
  }, [turn]);

  // 左にちょっと回転する関数
  const turnRight = useCallback(() => {
    turn(1);
  }, [turn]);

現在の向きに最も近い次のパノラマへ移動する処理

ストリートビューでは、現在のパノラマから接続されている他のパノラマへのリンクを取得できます
たとえば、東西南北に道が分岐する十字路がある場合、各方向のパノラマがリンクとして接続されています
以下の画像では、この上下左右の矢印がそれぞれのリンクを表しています

ユーザーが向いている方向に最も近いリンクを選択し、そのリンク先のパノラマに移動することで、歩いているように見せます

// 次のパノラマに移動する関数
  const moveForward = useCallback(() => {
    if (streetViewRef.current) {
      const currentPosition = streetViewRef.current.getPosition();
      const currentPov = streetViewRef.current.getPov();
      const links = streetViewRef.current.getLinks();
      if (!links) return;

      // 現在のheadingに最も近いリンクをソートで選択
      const closestLink = links.sort((a, b) => {
        const diffA = Math.abs(currentPov.heading! - a.heading!);
        const diffB = Math.abs(currentPov.heading! - b.heading!);
        return diffA - diffB;
      })[0];

      if (closestLink) {
        const streetViewService = new google.maps.StreetViewService();
        streetViewService.getPanorama(
          { pano: closestLink.pano! },
          (data, status) => {
            if (status === google.maps.StreetViewStatus.OK) {
              const nextPosition = data!.location!.latLng;

              // 距離を計算
              setDistance(
                distance +
                  computeDistanceBetween(currentPosition, nextPosition!)
              );
              streetViewRef.current!.setPano(closestLink.pano!);
            }
          }
        );
      }
    }
  }, [distance]);

Containerの実装

それぞれのカスタムフックを使って処理を実装します

  1. useMediaPipeを使ってlandmarkを取得
  2. landmarkをusePoseに与えて、歩いている/手を上げているなどの動きを取得
  3. 動きに合わせてuseStreetViewの処理を呼ぶ
const { turnLeft, turnRight, distance, moveForward } = useStreetView(
  lat,
  lng,
  started
);

const { landmarks } = useMediaPipe(webcamRef);
const { walkCount, walk, bothHandsUp, leftHandsUp, rightHandsUp } =
  usePose(landmarks);

useEffect(() => {
  if (walk) {
    moveForward();
  }
}, [walk]);

useEffect(() => {
    // 手を上げている間繰り返し処理をする
  if (leftHandsUp) {
    const id = setInterval(() => {
      turnLeft();
    }, 10);
    return () => clearInterval(id);
  }
  if (rightHandsUp) {
    const id = setInterval(() => {
      turnRight();
    }, 10);
    return () => clearInterval(id);
  }
}, [leftHandsUp, rightHandsUp]);

あとはUIに渡してうまいことやれば完成!

改善したいところ

とりあえず作ったものの改善点がいくつか

カメラから結構離れないと全身が映らない

全身が映らないと使えないが、広角じゃないカメラだと結構離れないと使えない
離れすぎると画面が遠すぎて見えない

MacBookProのカメラと画面サイズじゃ使い物にならなかった
デカいテレビに繋いで外付けカメラを使う必要があるのだけど、正直かなりめんどくさくてこれだと長続きしなそう

これやるならAndroidTVのアプリにするとかの方がまだマシそう

散歩の没入感を考えるならARグラスとか使うとかもアリ?
ARグラスほしいし買う口実にしたい

フラフラすると判定が怪しくなる

両手を上げた初期位置から動いてしまうと閾値を超えちゃったりするので歩いてる判定をしてくれなくなる
→足下げたところを新しい足の初期位置にするとかで解決しそう

楽しい感じにしたい

UIをもっとイケてる感じにしたい
ゲーム要素を加えたい
いっぱい歩いたらなんか出るとか
日々続けられる仕組みがないと多分飽きる
「歩いたカロリーがラーメンのカロリーを超えたらラーメン食べていい」とかそういう要素もいいかも

コード

https://github.com/7tsuno/walkInHome

おわりに

ネタっぽいものを作りたかったのにまともっぽいものが出来てしまった(当人比)
来年はちゃんとネタっぽいものを作ります

おしまい

CureApp テックブログ

Discussion