FURYU Tech Blog - フリュー株式会社

フリュー株式会社の開発者が技術情報を発信するブログです。

ONNX形式のMoveNetをC++で動かすまで[姿勢検出]

まず最初に

最近キャンプにはまっている、フリューでプリントシール機開発を行っている画像処理担当の三上です。

以前のブログでは、ONNX RuntimeをC++で組み込む方法をご紹介しました。
tech.furyu.jp

今回はその続編として、プリントシール機でも使用している ONNX形式のMoveNetモデルを紹介し、姿勢検出の実装手順を解説します。

特におすすめしたい方

  • C++で機械学習モデルを動かしたい方

  • 姿勢推定に興味がある方

  • ONNX形式のモデルを活用したい方

  • フリューのソフトウェアエンジニアの取り組みに関心がある方

  • ONNX形式のMoveNetをC++で動かす手順を理解し、簡単な姿勢検出を実装できるようになりたい方。

MoveNetとは?

MoveNetは、Googleによって開発された姿勢推定モデルです。画像内の人物の骨格、つまり関節の位置などを検出するために利用されます。
MoveNet: 超高速で高性能な姿勢検出モデル  |  TensorFlow Hub
主にリアルタイム用途を想定して、高速かつ高精度に動くように設計されています。
全身の17点の主要キーポイント(目、腰、手首、足首など)を検出し、それぞれの座標と信頼度を出力します。
骨格が取得できると、特定のポーズに対して処理を切り替えたりすることが可能になり、ソフトで出来ることが大きく広がります。
プリントシール機では、撮影時にポーズを認識して処理を切り替えるために、ONNX RuntimeとMoveNetを組み合わせて処理しています。 プリ機『Meidy』(メイディー)の脚長処理などで使用しています。

用途に合わせた2つのモデルタイプ

MoveNetは用途に合わせた「Lightning」と「Thunder」の2種類が用意されています。

特徴 入力画像サイズ 用途
Lightning 速度優先 192×192 ライブビューなどのリアルタイム処理
Thunder 精度優先 256×256 プリントシール機などの精度が求められるポーズ判定など

今回は精度の高いThunderを使用して説明を進めていきます。

開発環境と準備

ONNX Runtimeの導入や基本的な使い方については、以前のブログで詳しく解説していますので、そちらをご参照ください。
tech.furyu.jp

今回の開発では、以下の環境を使用します。

  • ライブラリ:ONNX Runtime 1.23.2
  • モデル:MoveNet V4 SinglePose Thunder(ONNX形式)

モデルの準備

MoveNet SinglePose Thunder のONNX形式のモデルをダウンロードします。
https://huggingface.co/Xenova/movenet-singlepose-thunder/tree/main/onnx
※今回はMoveNet SinglePose Thunder を使用しています。本記事で紹介する実装は「1人分の姿勢検出」を前提としている点にご注意ください。

複数のONNXモデルがありますが、model.onnxを選択します。(modelの後ろに型の表記がないものが標準のfp32版です。)

ページ中央の「Download file」ボタンを押すとダウンロードが開始します。

実際に動かしてみる

入力画像の前処理
今回用いるMoveNet SinglePose Thunderモデルは256×256の画像を想定して学習されており、入力として、各画素の各RGBチャンネルは 0~255 の整数値を取ります。

サイズが合わないと推論出来ませんので、事前に入力の画像を加工する必要があります。
今回は以下の手順で、入力画像を256×256に加工する方法を紹介します。

  1. 元画像の幅・高さを調べ、長辺を取得する。
  2. 元画像の長辺と長さが同じの背景を黒で塗りつぶした正方形画像を用意する。(以降入力画像と呼ぶ)
  3. 入力画像に元画像を貼り付ける。
  4. 入力画像を256×256にリサイズする。(入力画像の作成が完了)

以上の手順で256×256の画像を作成できます。 以下が完成した入力画像のイメージです。

ポーズをとっている筆者

画像を扱うイメージを伝えるために以下のインタフェースを用意しました。
開発環境によって関数を置き換えてください。

// 画像を扱うためのインタフェース
struct ImageRGB8
{
    // 幅width・高さheightの画像を生成する
    ImageRGB8(const int width, const int height);
    // 画像の幅
    int Width();
    // 画像の高さ
    int Height();
    // RGBデータを格納する1次元配列 (幅 * 高さ * 3)[R,G,B,R,G,B...]
    std::vector<uint8_t>& Data();
    // 画像のサイズを変更する
    void Resize(const int width, const int height);
    // srcの(sx, sy)を幅width・高さheight分、(dx, dy)にコピーする
    void CopyPaste(const int dx, const int dy, const ImageRGB8& src, const int sx, const int sy, const int width, const int height);
    // 指定した座標に線を描画する
    void DrawLine(const int x1, const int y1, const int x2, const int y2);
    // 画像ファイルを読み込む
    void LoadFile(const std::string& filepath);
};

では実際にコードを書いてみます。 入力画像の加工をコード内にイメージで入れています。

int main () {
    ImageRGB8 org;
    org.LoadFile("姿勢検出したい画像のパスを記述する");

    // 1. 元画像の幅・高さを調べ、長辺を取得する。
    int max_size = std::max(org.Width(), org.Height());

    // 2. 元画像の長辺と長さが同じの背景を黒で塗りつぶした正方形画像を用意する。
    ImageRGB8 in_img(max_size, max_size);

    // 3. 入力画像に元画像を貼り付ける。
    in_img.CopyPaste(0, 0, org, 0, 0, org.Width(), org.Height());

    // 4. 入力画像を256×256にリサイズする。(入力画像の作成が完了)  
    in_img.Resize(256, 256);

    // ダウンロードしたMoveNetのモデルパスを記述する
    const wchar_t* model_path = L"model.onnx";

    // MoveNetモデルからセッション作成
    Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "movenet");
    Ort::SessionOptions session_options;
    Ort::Session session(env, model_path, session_options);

    // モデルの入力と出力の名前を確認する。
    // モデルごとに名前が異なるため、プログラム側で自動取得する。
    Ort::AllocatorWithDefaultOptions allocator;
    std::string input_name_str = session.GetInputNameAllocated(0, allocator).get();
    std::string output_name_str = session.GetOutputNameAllocated(0, allocator).get();
    const char* input_names[] = { input_name_str.c_str() };
    const char* output_names[] = { output_name_str.c_str() };

    // 入力の型・形状を自動取得する。
    auto in_tensor_info = session.GetInputTypeInfo(0).GetTensorTypeAndShapeInfo();
    const std::vector<int64_t> input_shape = in_tensor_info.GetShape();
    std::vector<int32_t> input_data(input_shape[0] * input_shape[1] * input_shape[2] * input_shape[3]);

    // 入力画像をinput_dataに格納する(256×256×3を1次元で格納)。
    const std::vector<uint8_t>& data = in_img.Data();
    for (int i = 0; i < int(input_data.size()); i++) {
        // RGBの各チャネルを順番に格納する。[R,G,B, R,G,B, ...]
        input_data[i] = int32_t(data[i]);
    }

    // 出力の型・形状を自動取得する。
    auto out_tensor_info = session.GetOutputTypeInfo(0).GetTensorTypeAndShapeInfo();
    const std::vector<int64_t> output_shape = out_tensor_info.GetShape();
    std::vector<float> output_data(output_shape[0] * output_shape[1] * output_shape[2] * output_shape[3]);

    // メモリ情報(CPUのメモリを使用することをONNX Runtimeに伝える)
    Ort::MemoryInfo mem = Ort::MemoryInfo::CreateCpu(OrtDeviceAllocator, OrtMemTypeCPU);

    // テンソル作成
    Ort::Value input_tensor = Ort::Value::CreateTensor<int>(
        mem, input_data.data(), input_data.size(), input_shape.data(), input_shape.size());
    Ort::Value output_tensor = Ort::Value::CreateTensor<float>(
        mem, output_data.data(), output_data.size(), output_shape.data(), output_shape.size());
 
    // 推論
    Ort::RunOptions run_opts;
    session.Run(run_opts, input_names, &input_tensor, 1U, output_names, &output_tensor, 1U);

姿勢検出の結果について

推論が完了するとoutput_dataの中に、51個の浮動小数が格納されます。
一見するとわかりにくいですが、これらは17個のキーポイントに対応した値で明確な意味があります。

中身は何なのか

MoveNet の出力は、17 個のキーポイント分の [y, x, score]が連続した 51(17×3) 要素の配列として返されます。
※xよりもyが先に格納されていますので注意してください。

それぞれの項目の意味です。

項目 意味
y 画像の高さに対する相対座標(0〜1)
x 画像の幅に対する相対座標(0〜1)
score 信頼度

これらの内容がコンテナ内に[y0, x0, score0, ~ y16, x16, score16]といったような順番で並んでいます。
これらは各キーポイントを表しており、次のような順番になっています。
0: 鼻 1: 左目 2: 右目 3: 左耳 4: 右耳 5: 左肩 6: 右肩 7: 左肘 8: 右肘 9: 左手首 10: 右手首 11: 左腰 12: 右腰 13: 左膝 14: 右膝 15: 左足首 16: 右足首
※MoveNet SinglePose は 1 人を対象とするため、検出されるキーポイントは17点

github.com
それでは、取得したキーポイントを使って画像に骨格を描画してみます。 信頼度は用途によって閾値を調整できます。公式で推奨されている信頼度は0.3で、今回はこの値で低い信頼度の推定を除外します。 ※出力のyとxは画像に対する相対座標なので、描画時には入力画像の高さ・幅を掛けてピクセル座標に変換します。

    // output_dataを使用して姿勢検出の結果を画像に描画する
    // 描画するキーポイントの組み合わせ
    const int key_point_pairs[][2] = {
        {0, 1}, {0, 2}, {1, 3}, {2, 4},       // 鼻-目-耳
        {5, 6}, {5, 7}, {7, 9}, {6, 8}, {8, 10}, // 肩-肘-手首
        {5, 11}, {6, 12}, {11, 12},           // 肩-腰
        {11, 13}, {13, 15}, {12, 14}, {14, 16}  // 腰-膝-足首
    };

    // 描画処理
    // 元画像の長辺を掛けてピクセル座標に変換(座標は画像の相対位置なので画像のサイズを乗算する)
    for (const auto& edge : key_point_pairs) {
        int point1 = edge[0];
        int point2 = edge[1];
        float y1 = output_data[point1 * 3] * (float)max_size;
        float x1 = output_data[point1 * 3 + 1] * (float)max_size;
        float score1 = output_data[point1 * 3 + 2];
        float y2 = output_data[point2 * 3] * (float)max_size;
        float x2 = output_data[point2 * 3 + 1] * (float)max_size;
        float score2 = output_data[point2 * 3 + 2];

        // 両方の点の信頼度が高い場合のみ線を引く
        if (score1 > 0.3f && score2 > 0.3f) {
            //画像に線を描画する
            org.DrawLine(int(x1), int(y1), int(x2), int(y2));
        }
    }
}

姿勢を検出された筆者

まとめ

ONNX形式のMoveNetモデルをC++で実行し、姿勢推定の結果を描画するところまでを実施しました。 MoveNetの出力の構造を理解すると、実際の組み込みが進めやすくなります。
フリューでも製品に使用しており、取得したキーポイントはポーズの検出や動作に応じた処理の切り替えなど、さまざまな用途で役に立ちます。
この記事を読んでもらい、実際に組み込んでいただけると幸いです。 今後もプリントシール機で使用している技術や開発の裏側を紹介していきますので、ぜひチェックしていただければ嬉しいです!