OpenCVのDNNモジュールで深度推定してみる

この投稿は[OpenCV Advent Calendar 2020](https://qiita.com/advent-calendar/2020/opencv)の14日目の記事です。

はじめに 
-------
OpenCVのDNNモジュールで各モデルを試してみようという[リポジトリ](https://github.com/UnaNancyOwen/OpenCVDNNSample)で細々と動作を確認したりしています。
そのなかでClassificationやObject Detection、Pose Estimationなどのタスクのモデルはいろいろ動かしてみたけど、そういえばDepth Estimationのモデルは動かしたことなかったな…って思ったのでやってみました。

DNNモジュールとは？
------------------
OpenCVでは機能ごとにモジュール（コア機能ならCore、GUI機能ならHighguiなど）に分かれており、ディープラーニングの推論機能を担当するのがDNNモジュールです。
学習はTensorflowやPyTorch、Darknetなどの各フレームワークやAzure Cognitive ServicesのCustom Visionなどのクラウドサービスで行い、DNNモジュールでは学習されたモデルを利用して推論を行います。

MiDaSとは？
-----------
MiDaSとはIntel ISL（Intelligent Systems Lab）が公開している単眼のRGB画像からの深度推定のモデルです。
MiDaSの論文（"Towards Robust Monocular Depth Estimation: Mixing Datasets for Zero-shot Cross-dataset Transfer \(TPAMI 2020\)"）は[こちら](https://arxiv.org/abs/1907.01341v3)で公開されています。
![midas.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34834/224bb72b-6995-52fa-9ed5-eec3d590fd94.png)

論文ではザックリこんな内容が書かれているらしい。
> 単眼画像からの深度推定を汎用的に良い感じにやるには大規模で多様なデータを用意しないといけないよ。
> でも、世の中にあるそれぞれのデータセットはスケールが異なったりセンサーが違ったりして特性がバラバラだから単純にごちゃまぜにしては使いにくいよね？
> この論文では上手いことそれらの違いを吸収していろんなデータセットを使って学習できるようにするよ！

実装はGitHubでオープンソースで公開されており、ライセンスもMIT Licenseで扱いやすい。

* [intel-isl/MiDaS](https://github.com/intel-isl/MiDaS)


サンプルコード
-------------
### 動作確認環境
* Windows 10
* Visual Studio 2019 (16.8.2)
* OpenCV 4.5.0 <sup>*1</sup>
* CMake 3.17.5
* CUDA Toolkit 10.1 <sup>*2 Option</sup>
* cuDNN 7.6.5 <sup>*2 Option</sup>

### ソースコード
ソースコードはGitHubの以下のリポジトリで公開しています。

* [UnaNancyOwen/OpenCVDNNSample/sample/Depth Estimation/MiDaS](https://github.com/UnaNancyOwen/OpenCVDNNSample/tree/master/sample/Depth%20Estimation/MiDaS)

```cpp
#include <iostream>
#include <string>
#include <vector>
#include <opencv2/opencv.hpp>
#include <opencv2/dnn.hpp>

// Get Output Layers Name
std::vector<std::string> getOutputsNames( const cv::dnn::Net& net )
{
    static std::vector<std::string> names;
    if( names.empty() ){
        std::vector<int32_t> out_layers = net.getUnconnectedOutLayers();
        std::vector<std::string> layers_names = net.getLayerNames();
        names.resize( out_layers.size() );
        for( size_t i = 0; i < out_layers.size(); ++i ){
            names[i] = layers_names[out_layers[i] - 1];
        }
    }
    return names;
}

int main( int argc, char* argv[] )
{
    // Open Video Capture
    cv::VideoCapture capture = cv::VideoCapture( 0 );
    if( !capture.isOpened() ){
        return -1;
    }

    // Read Network
    const std::string model = "../model-f6b98070.onnx"; // MiDaS v2.1 Large
    //const std::string model = "../model-small.onnx"; // MiDaS v2.1 Small
    cv::dnn::Net net = cv::dnn::readNet( model );
    if( net.empty() ){
        return -1;
    }

    // Set Preferable Backend and Target
    net.setPreferableBackend( cv::dnn::DNN_BACKEND_OPENCV );
    net.setPreferableTarget( cv::dnn::DNN_TARGET_CPU );

    while( true ){
        // Read Frame
        cv::Mat input;
        capture >> input;
        if( input.empty() ){
            cv::waitKey( 0 );
            break;
        }
        if( input.channels() == 4 ){
            cv::cvtColor( input, input, cv::COLOR_BGRA2BGR );
        }

        // Create Blob from Input Image
        // MiDaS v2.1 Large ( Scale : 1 / 255, Size : 384 x 384, Mean Subtraction : ( 123.675, 116.28, 103.53 ), Channels Order : RGB )
        cv::Mat blob = cv::dnn::blobFromImage( input, 1 / 255.f, cv::Size( 384, 384 ), cv::Scalar( 123.675, 116.28, 103.53 ), true, false );
        // MiDaS v2.1 Small ( Scale : 1 / 255, Size : 256 x 256, Mean Subtraction : ( 123.675, 116.28, 103.53 ), Channels Order : RGB )
        //cv::Mat blob = cv::dnn::blobFromImage( input, 1 / 255.f, cv::Size( 256, 256 ), cv::Scalar( 123.675, 116.28, 103.53 ), true, false );

        // Set Input Blob
        net.setInput( blob );

        // Run Forward Network
        cv::Mat output = net.forward( getOutputsNames( net )[0] );

        // Convert Size to 384x384 from 1x384x384
        const std::vector<int32_t> size = { output.size[1], output.size[2] };
        output = cv::Mat( static_cast<int32_t>( size.size() ), &size[0], CV_32F, output.ptr<float>() );

        // Resize Output Image to Input Image Size
        cv::resize( output, output, input.size() );

        // Visualize Output Image
        // 1. Normalize ( 0.0 - 1.0 )
        // 2. Scaling ( 0 - 255 )
        double min, max;
        cv::minMaxLoc( output, &min, &max );
        const double range = max - min;
        output.convertTo( output, CV_32F, 1.0 / range, - ( min / range ) );
        output.convertTo( output, CV_8U, 255.0 );

        // Show Image
        cv::imshow( "input", input );
        cv::imshow( "output", output );

        const int32_t key = cv::waitKey( 1 );
        if( key == 'q' ){
            break;
        }
    }

    cv::destroyAllWindows();

    return 0;
}
```

### 解説
#### 1. ネットワークの読み込みと設定
```cpp
// Read Network
const std::string model = "../model-f6b98070.onnx"; // MiDaS v2.1 Large
//const std::string model = "../model-small.onnx"; // MiDaS v2.1 Small
cv::dnn::Net net = cv::dnn::readNet( model );
if( net.empty() ){
    return -1;
}
```
MiDaSの学習済みモデルを読み込みます。
OpenCVのDNNモジュールはPyTorchの学習済みモデルを直接読み込むことはできない。
そこでPyTorchのモデルを[こちらの手順](https://github.com/intel-isl/MiDaS/tree/master/tf#make-onnx-model-from-downloaded-pytorch-model-file)に従ってONNXに変換したモデルを利用する。
（[リリースページ](https://github.com/intel-isl/MiDaS/releases/tag/v2_1)にてONNXに変換済みのモデルも配布されています。）

```cpp
// Set Preferable Backend and Target
net.setPreferableBackend( cv::dnn::DNN_BACKEND_OPENCV );
net.setPreferableTarget( cv::dnn::DNN_TARGET_CPU );
```
推論を実行するバックエンドとターゲットを設定する。
ここではOpenCVのバックエンド（<code>DNN_BACKEND_OPENCV</code>）でCPUをターゲット（<code>DNN_TARGET_CPU</code>）として推論するように設定している。
OpenCVのDNNモジュールではそれぞれのバックエンドで指定できるターゲットが異なるので[こちらの表](https://docs.opencv.org/4.5.0/db/d30/classcv_1_1dnn_1_1Net.html#a9dddbefbc7f3defbe3eeb5dc3d3483f4)を参考に設定する。（「+」がそれぞれのバックエンドでサポートしているターゲット）
![2020-12-03_14h07_09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34834/d0400b9b-7be7-b041-babe-dc30aff8118c.png)
たとえば、GPUを利用して推論したい場合はバックエンドに<code>DNN_BACKEND_CUDA</code>、ターゲットに<code>DNN_TARGET_CUDA</code>を指定する。<sup>*2</sup>

#### 2. 入力データの作成
```cpp
// Create Blob from Input Image
// MiDaS v2.1 Large ( Scale : 1 / 255, Size : 384 x 384, Mean Subtraction : ( 123.675, 116.28, 103.53 ), Channels Order : RGB )
cv::Mat blob = cv::dnn::blobFromImage( input, 1 / 255.f, cv::Size( 384, 384 ), cv::Scalar( 123.675, 116.28, 103.53 ), true, false );
// MiDaS v2.1 Small ( Scale : 1 / 255, Size : 256 x 256, Mean Subtraction : ( 123.675, 116.28, 103.53 ), Channels Order : RGB )
//cv::Mat blob = cv::dnn::blobFromImage( input, 1 / 255.f, cv::Size( 256, 256 ), cv::Scalar( 123.675, 116.28, 103.53 ), true, false );

// Set Input Blob
net.setInput( blob );
```
入力データは<code>cv::dnn::blobFromImage()</code>で作成する。
画像のサイズやスケールなどのパラメータはモデルによって異なるので確認する。
（間違ってるかもなので教えてほしい。）

#### 3. 推論
```cpp
// Get Output Layers Name
std::vector<std::string> getOutputsNames( const cv::dnn::Net& net )
{
    static std::vector<std::string> names;
    if( names.empty() ){
        std::vector<int32_t> out_layers = net.getUnconnectedOutLayers();
        std::vector<std::string> layers_names = net.getLayerNames();
        names.resize( out_layers.size() );
        for( size_t i = 0; i < out_layers.size(); ++i ){
            names[i] = layers_names[out_layers[i] - 1];
        }
    }
    return names;
}
```
```cpp
// Run Forward Network
cv::Mat output = net.forward( getOutputsNames( net )[0] );
```
<code>cv::dnn::Net::forward()</code>にネットワークの出力レイヤーの名前を指定して推論する。
ここでは出力レイヤーの名前はネットワークの中から接続がないレイヤーの名前を抽出して利用しているが、出力レイヤーの名前を文字列で直接指定してもよい。

#### 4. 解釈
```cpp
// Convert Size to 384x384 (256x256) from 1x384x384 (1x256x256)
const std::vector<int32_t> size = { output.size[1], output.size[2] };
output = cv::Mat( static_cast<int32_t>( size.size() ), &size[0], CV_32F, output.ptr<float>() );

// Resize Output Image to Input Image Size
cv::resize( output, output, frame.size() );
```
MiDaSのLargeモデルでは1x384x384の<code>cv::Mat</code>で推論結果が出力される。
これを384x384の画像形式に整え、入力画像のサイズにリサイズしてやる。
（Smallモデルでは1x256x256で推論結果が出力される。）

#### 5. 可視化
```cpp
// Visualize Output Image
// 1. Normalize ( 0.0 - 1.0 )
// 2. Scaling ( 0 - 255 )
double min, max;
cv::minMaxLoc( output, &min, &max );
const double range = max - min;
output.convertTo( output, CV_32F, 1.0 / range, - ( min / range ) );
output.convertTo( output, CV_8U, 255.0 );
```
あとはお好みの方法で可視化してやる。
ここでは最小値-最大値を0.0-1.0に正規化、0-255のグレースケール画像に変換して可視化している。
（それっぽくテキトーにやっただけなのでもう少し良い方法がある気がする。教えてほしい。）

実行結果
--------
MiDaS v2.1 LargeのモデルはCPUで推論すると1fpsくらい、GPUで推論するとリアルタイムに動作した。<sup>*3</sup>
MiDaS v2.1 SmallのモデルはCPUでの推論でもリアルタイムに動作した。
全体的に形状がわかる程度には妥当な推論できていると思う。
人物の顔や指などの細かいディティールはちょっと厳しいのかな？

* 例1：車と道路
![input.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34834/3a710cd9-80d8-43db-b83f-eea61ad39b88.jpeg)
![output.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34834/0c28b7a8-c411-5250-5df2-adee7a72b9b8.png)

* 例2：人物
![human.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34834/595d8e64-0e19-a013-e128-014f29075322.png)

おわりに
--------
MiDaSは思っていたよりも綺麗に深度推定できてちょっと驚きました。
もう少し細かいディティールが推定できるようになるといいな。

OpenCVのDNNモジュールは扱いやすくとりあえず動かしてみるまでの敷居が低くなります。
実際に私のような初心者でも難なく動かせたりして楽しいです。
今後も最新のフレームワークやモデル（レイヤー）に追従してサポートしてくれると嬉しい。
（PyTorchのモデルがそのまま読み込めるようになるといいな。）

明日は@satsukiyaさんのCUDAについての記事です。

---
<sup>1 MiDaS v2.1 Small（model-small.onnx）はOpenCV 4.5.0では読み込めなかった。2020/12/04頃にOpenCV master/HEAD（22d64ae）で確認すると読み込めた。どこかのコミットで修正されたと思われるがそこまでは確認していない。</sup>
<sup>2 GPUで推論するにはCUDA対応のオプション（OPENCV_DNN_CUDA）をONにしてOpenCVをビルドする必要がある。</sup>
<sup>3 実行環境はCPU：Intel Core-i9 9900K、GPU：NVIDIA GeForce GTX 1080 Tiです。</sup>