【MediaPipe】Landmark データ中の z 座標が表す意味を解析し、本来のデータを抽出した

2020.06.02

カフェチームの山本です。

前回は、MediaPipeのプログラムを修正し、複数人の手を同時に検出できるようになりました。

【MediaPipe】Multi Hand Trackingで3つ以上の手を骨格検出する(解決編2)

今回は、手の形状を認識することを目的として、Multi Hand Trackingが出力する、手のLandmarkデータについて調べた結果を書いていきます。おおよそ、こちらのコミットの説明になります(開発中のもののため、ソースコードの動作は保証できません、ご了承ください)。

(MediaPipeに関連する記事はこちらにまとめてあります。)

【MediaPipe】投稿記事まとめ

Landmarkデータの中身

手のポーズを認識する方法として、こちらの方がプログラムを公開されています。この方法では手が正面から映っている必要がありますが、カフェで撮影した動画では手を上から撮影し、手の形もまちまちなため、そのままでは利用できそうにありません。そこで、処理を修正するために、x,y,z座標をLandmarkデータから取り出すことにしました。

データを見てみる

Multi Hand Trackingが出力するLandmarkのデータは、以下のようになっています(これはこちらの記事で取り出したデータで、JSONに変換済みのものです)。手首や指の関節など21点のx,y,z座標が並んでいます。

{
  "landmark": [
    {
      "x": 0.49395758,
      "y": 0.66140294,
      "z": 0.008392081
    },
    {
      "x": 0.5250709,
      "y": 0.6283441,
      "z": -6.6685047
    },
    {
      "x": 0.54412884,
      "y": 0.5751445,
      "z": -9.635668
    },
    ...
    {
      "x": 0.4316161,
      "y": 0.49671805,
      "z": -10.572263
    },
    {
      "x": 0.424795,
      "y": 0.4668986,
      "z": -13.554913
    },
    {
      "x": 0.41967082,
      "y": 0.43840015,
      "z": -16.109375
    }
  ]
}

x,y座標はレンジが0~1の間になっています。プログラムを見ると、データ形式はNormalizedLandmarkと書かれていることから、x,y座標は正規化されていそうです。実際に検出された結果と合わせて見ると、画像のwidth, heightをそれぞれかけると画像の位置にあたることがわかります。

しかし、z座標はよくわからない感じになっています。レンジがx,y座標異なっており、50くらいの値や、マイナスの値もあります。

z座標を詳しく見てみる

いくつかのLandmarkのデータを見てみると、z座標に関して以下の点が共通していました。

  • 手首のz座標はほぼ0(厳密に0ではない、かなり小さい値:0.008392081など)
  • 手前方向にマイナス、奥方向にプラスになる
  • z座標の値と、検出結果で描画されている関節の丸の大きさには、相関関係はあるが、値そのものが反映されているわけではない。 例えば、ある手で z=-5 の点と、別の手で z=-50 の点が、同じ大きさの丸で描画されることもある。(描画の際、手ごとに正規化されている模様)
  • z座標のレンジはおよそ以下のよう
    • 手がカメラに対して垂直だと:-10くらいまで
    • 手が傾いていると:-50くらいまで
  • 映像を拡大して手を検出させても、z座標は大きく変わらない(2倍に拡大しても、z座標のレンジは2倍にならず、むしろほぼ変わらない)

また、Graphを見てみると、以下の図のようになっています(v0.7.5)。

Landmarkデータは、TfLiteInferenceCalculatorで検出されたTensorFlowのrawデータを、TfLiteTensorsToLandmarksCalculatorが変換して出力していそうです。 実際、TfLiteTensorsToLandmarksCalculatorを見てみると、コードは以下のようになっており、出力ストリートのNORM_LANDMARKSには、x,y座標を画像のwidth,heightで正規化し、zをoptionのnormalize_zで割っていることがわかります。

mediapipecalculators/flite.flite_tensors_to_landmarks_calculator.cc

// Output normalized landmarks if required.
  if (cc->Outputs().HasTag("NORM_LANDMARKS")) {
    NormalizedLandmarkList output_norm_landmarks;
    // for (const auto& landmark : output_landmarks) {
    for (int i = 0; i < output_landmarks.landmark_size(); ++i) {
      const Landmark& landmark = output_landmarks.landmark(i);
      NormalizedLandmark* norm_landmark = output_norm_landmarks.add_landmark();
      norm_landmark->set_x(static_cast<float>(landmark.x()) /
                           options_.input_image_width());
      norm_landmark->set_y(static_cast<float>(landmark.y()) /
                           options_.input_image_height());
      norm_landmark->set_z(landmark.z() / options_.normalize_z());
    }
    cc->Outputs()
        .Tag("NORM_LANDMARKS")
        .AddPacket(MakePacket<NormalizedLandmarkList>(output_norm_landmarks)
                       .At(cc->InputTimestamp()));
  }

optionはprotoファイルにかかれているので、コードを見てみると、normalize_zのデフォルト値は1であることがわかりました。(= 5 と書かれているのは、Protocol Bufferの型式で、message内のIDを表しており、実際の値ではありません)

mediapipecalculators flite flite_tensors_to_landmarks_calculator.proto

// A value that z values should be divided by.
  optional float normalize_z = 5 [default = 1.0];

よって、出力されているLandmarkデータのz座標は、TfLiteInferenceCalculatorで検出に使用した画像内でつけられた値がそのまま出力されていた、ことがわかりました。TfLiteInferenceCalculatorの入力画像の大きさは256*256なので、そのスケールで学習データが作られていることが推測され、z座標も同じスケールでアノテーションされていると推測することができます。もともと得られていた出力結果のz座標も、レンジが50程度であることから、間違ってはなさそうです。

元スケールのデータを取り出す

先程の推測から、元のスケールのデータが得られれば、x,y,z座標のスケールの整合がとれたLandmarkデータになりそうです。

まず考えられる方法として、mediapipe/graphs/hand_tracking/multi_hand_tracking_desktop_live.pbtxtのLandmarkデータから計算できそうですが、x,y座標が何回か変換されており、逆変換の処理が面倒そうです。

なので、TfLiteTensorsToLandmarksCalculatorの出力結果を、そのまま抽出するのが良さそうです。上の画像中のNORM_LECTを取り出すのも良いのですが、コードを見てみるとLANDMARKSという出力ストリームが用意されており、Graphを接続すると、スケールが変換していないデータをそのまま出力してくれそうです。

mediapipe/calculators/flite/flite_tensors_to_landmarks_calculator.cc

// Output absolute landmarks.
  if (cc->Outputs().HasTag("LANDMARKS")) {
    cc->Outputs()
        .Tag("LANDMARKS")
        .AddPacket(MakePacket<LandmarkList>(output_landmarks)
                       .At(cc->InputTimestamp()));
  }

Graphの変更

TfLiteInferenceCalculatorのLANDMARKSからデータを取り出すため、Graphを以下のように変更していきます。

HandLandmarkSubgraph

出力用のストリームを追加します(12行目)

mediapipe/graphs/hand_tracking/subgraphs/hand_landmark_cpu.pbtxt

output_stream: "PRESENCE:hand_presence"
output_stream: "PRESENCE_SCORE:hand_presence_score"
output_stream: "HANDEDNESS:handedness"
output_stream: "LANDMARKS_RAW:landmarks_raw"

# Crops the rectangle that contains a hand from the input image.
node {

出力用ストリームにTfLiteTensorsToLandmarksCalculatorのLANDMARKSを接続します(138行目)

mediapipe/graphs/hand_tracking/subgraphs/hand_landmark_cpu.pbtxt

calculator: "TfLiteTensorsToLandmarksCalculator"
  input_stream: "TENSORS:landmark_tensors"
  output_stream: "NORM_LANDMARKS:landmarks"
  output_stream: "LANDMARKS:landmarks_raw"
  node_options: {
    [type.googleapis.com/mediapipe.TfLiteTensorsToLandmarksCalculatorOptions] {
      num_landmarks: 21

MultiHandLandmarkSubgraph

出力用のストリームを追加します(12~13行目)

mediapipe/graphs/hand_tracking/subgraphs/multi_hand_landmark.pbtxt

output_stream: "LANDMARKS:filtered_multi_hand_landmarks"
# A vector of NormalizedRect, one per each hand.
output_stream: "NORM_RECTS:filtered_multi_hand_rects_for_next_frame"
# A vector of RawLandmarks, one set per each hand.
output_stream: "LANDMARKS_RAW:filtered_multi_hand_landmarks_raw"

# Outputs each element of multi_hand_rects at a fake timestamp for the rest
# of the graph to process. Clones the input_video packet for each

HandLandmarkSubgraphの他の出力結果と同様に、EndLoopとFilterのCalculatorを追加します。(69~75行目、)

mediapipe/graphs/hand_tracking/subgraphs/multi_hand_landmark.pbtxt

output_stream: "ITERABLE:multi_hand_rects_for_next_frame"
}

node {
  calculator: "EndLoopLandmarkListVectorCalculator"
  input_stream: "ITEM:single_hand_landmarks_raw"
  input_stream: "BATCH_END:single_hand_rect_timestamp"
  output_stream: "ITERABLE:multi_hand_landmarks_raw"
}

# Filters the input vector of landmarks based on hand presence value for each
# hand. If the hand presence for hand #i is false, the set of landmarks
# corresponding to that hand are dropped from the vector.

mediapipe/graphs/hand_tracking/subgraphs/multi_hand_landmark.pbtxt

input_stream: "CONDITION:multi_hand_presence"
  output_stream: "ITERABLE:filtered_multi_hand_rects_for_next_frame"
}

node {
  calculator: "FilterLandmarkRawListCollectionCalculator"
  input_stream: "ITERABLE:multi_hand_landmarks_raw"
  input_stream: "CONDITION:multi_hand_presence"
  output_stream: "ITERABLE:filtered_multi_hand_landmarks_raw"
}

出力用のストリームにFilterの出力を接続します(36行目)

mediapipe/graphs/hand_tracking/subgraphs/multi_hand_landmark.pbtxt

output_stream: "LANDMARKS:single_hand_landmarks"
  output_stream: "NORM_RECT:single_hand_rect_from_landmarks"
  output_stream: "PRESENCE:single_hand_presence"
  output_stream: "LANDMARKS_RAW:single_hand_landmarks_raw"
}

# Collects the boolean presence value for each single hand into a vector. Upon

MultiHandTrackingGraph

出力用のストリームを作成します。

mediapipe/graphs/hand_tracking/multi_hand_tracking_desktop_live.pbtxt

output_stream: "VIDEOS:output_video"
output_stream: "DETECTIONS:multi_palm_detections"
output_stream: "LANDMARKS:multi_hand_landmarks"
output_stream: "PALMRECTS:multi_palm_rects"
output_stream: "HANDRECTS:multi_hand_rects"
output_stream: "HANDRECTS_FROM_LANDMARKS:multi_hand_rects_from_landmarks"
output_stream: "LANDMARKS_RAW:multi_hand_landmarks_raw"

# Determines if an input vector of NormalizedRect has a size greater than or
# equal to the provided min_size.

出力用ストリームにのMultHandLandmarkSubgraph出力を接続します。

mediapipe/graphs/hand_tracking/multi_hand_tracking_desktop_live.pbtxt

input_stream: "NORM_RECTS:multi_hand_rects"
  output_stream: "LANDMARKS:multi_hand_landmarks"
  output_stream: "NORM_RECTS:multi_hand_rects_from_landmarks"
  output_stream: "LANDMARKS_RAW:multi_hand_landmarks_raw"
}

# Caches a hand rectangle fed back from MultiHandLandmarkSubgraph, and upon the

プログラムの変更

MultiHandLandmarkSubgraphで使う、EndLoopとFilterのクラスを追加します。

mediapipe/calculators/core/end_loop_calculator.cc

typedef EndLoopCalculator<std::vector<TfLiteTensor>> EndLoopTensorCalculator;
REGISTER_CALCULATOR(EndLoopTensorCalculator);

typedef EndLoopCalculator<std::vector<::mediapipe::LandmarkList>>
    EndLoopLandmarkListVectorCalculator;
REGISTER_CALCULATOR(EndLoopLandmarkListVectorCalculator);

}  // namespace mediapipe

mediapipe/calculators/util/filter_collection_calculator.cc

FilterClassificationListCollectionCalculator;
REGISTER_CALCULATOR(FilterClassificationListCollectionCalculator);

typedef FilterCollectionCalculator<
    std::vector<::mediapipe::LandmarkList>>
    FilterLandmarkRawListCollectionCalculator;
REGISTER_CALCULATOR(FilterLandmarkRawListCollectionCalculator);

}  // namespace mediapipe

メインプログラムからデータを取り出すように、処理を追加します。

mediapipe/examples/desktop/demo_run_graph_main.cc

constexpr char kOutputPalmRects[] = "multi_palm_rects";
constexpr char kOutputHandRects[] = "multi_hand_rects";
constexpr char kOutputHandRectsFromLandmarks[] = "multi_hand_rects_from_landmarks";
constexpr char kOutputLandmarksRaw[] = "multi_hand_landmarks_raw";
constexpr char kWindowName[] = "MediaPipe";

DEFINE_string(

mediapipe/examples/desktop/demo_run_graph_main.cc

graph.AddOutputStreamPoller(kOutputHandRects));
  ASSIGN_OR_RETURN(mediapipe::OutputStreamPoller poller_hand_rects_from_landmarks,
                   graph.AddOutputStreamPoller(kOutputHandRectsFromLandmarks));
  ASSIGN_OR_RETURN(mediapipe::OutputStreamPoller poller_landmarks_raw,
                   graph.AddOutputStreamPoller(kOutputLandmarksRaw));

  MP_RETURN_IF_ERROR(graph.StartRun({}));

mediapipe/examples/desktop/demo_run_graph_main.cc

}
    }

    if(poller_landmarks_raw.QueueSize() > 0){
      mediapipe::Packet packet_landmarks_raw;
      if(!poller_landmarks_raw.Next(&packet_landmarks_raw)) break;
      
      auto &output_landmarks_raw = packet_landmarks_raw.Get<std::vector<mediapipe::LandmarkList>>();

      // output file
      for (int j = 0; j < output_landmarks_raw.size(); j++)
      {
        std::ostringstream os;
        os << output_dirpath + "/"
          << "iLoop=" << iLoop << "_"
          << "landmarkRaw_"
          << "j=" << j << ".txt";
        std::ofstream outputfile(os.str());

        std::string serializedStr;
        output_landmarks_raw[j].SerializeToString(&serializedStr);
        outputfile << serializedStr << std::flush;
      }
    }

    std::cout << std::endl;
    ++iLoop;
  }

ProtobufデータをJSONに変換するPythonに処理を追加します。

convertProtobufToJson.py

handRectFromLandmarksFilesFiltered = [
    (handRectFromLandmarksFile[0], handRectFromLandmarksFile[1].replace("\\", "/")) for handRectFromLandmarksFile in handRectFromLandmarksFiles if handRectFromLandmarksFile[0]]

landmarkRawFiles = [(re.findall(
    r"iLoop=(\d+)_landmarkRaw_j=(\d+).txt", outputFile), outputFile) for outputFile in outputFiles]
landmarkRawFilesFiltered = [
    (landmarkRawFile[0], landmarkRawFile[1].replace("\\", "/")) for landmarkRawFile in landmarkRawFiles if landmarkRawFile[0]]

for detectionFile in detectionFilesFiltered:
    with open(detectionFile[1], "rb") as f:
        content = f.read()

convertProtobufToJson.py

with open(handRectFromLandmarksFileOutput, "w") as f:
        f.write(jsonObj) 

for landmarkRawFile in landmarkRawFilesFiltered:
    with open(landmarkRawFile[1], "rb") as f:
        content = f.read()

    landmarkRaw = LandmarkList()
    landmarkRaw.ParseFromString(content)
    jsonObj = MessageToJson(landmarkRaw)

    landmarkRawFileOutput = landmarkRawFile[1].replace("txt", "json")
    with open(landmarkRawFileOutput, "w") as f:
        f.write(jsonObj) 

出力データの確認

出力されたLandmarkデータを見てみると、正しく検出されてそうなことがわかります。(手首の根本から親指の先端までの点です)

{
  "landmark": [
    {
      "x": 128.75655,
      "y": 222.813,
      "z": 0.008089952
    },
    {
      "x": 153.39214,
      "y": 210.25851,
      "z": -13.0789995
    },
    {
      "x": 170.4613,
      "y": 185.2839,
      "z": -18.956207
    },
    {
      "x": 182.66098,
      "y": 162.96127,
      "z": -23.279835
    },
    {
      "x": 200.70265,
      "y": 149.5498,
      "z": -29.19745
    },
    ...
  ]
}

上のデータは、下のようなフレームの検出結果です。親指の指先が少し手前にきており、正しい座標を抽出できたと言えそうです。

まとめ

今回は、手のポーズを認識することを目的として、x,y,z座標のスケールの整合が取れたデータを取り出しました。すでに描画で利用されていたLandmarkデータでは、スケールがおかしかったため、Landmark検出処理の直後からデータを取り出すことで、本来のスケールのデータを抽出できました

今後は、このスケールの整合が取れたLandmarkデータをもとに、手のポーズ認識に取り組んでいきます。

参考にさせていただいたページ

Simple Hand Gesture Recognition Code - Hand tracking - Mediapipe https://gist.github.com/TheJLifeX/74958cc59db477a91837244ff598ef4a