OpenCVのstitchモジュールのバグを直した話

はじめに
====
 - 本記事は[OpenCV Advent Calendar 2022](https://qiita.com/advent-calendar/2022/opencv) の24日目の記事です。
 -  23日目の記事は[@Kazuhito](https://qiita.com/Kazuhito)さんによる[NiceGUI と OpenCV を組み合わせたい](https://qiita.com/Kazuhito/items/76d39895b5f96a3f7314)でした
    - その他の記事は[目次](https://qiita.com/advent-calendar/2022/opencv)をご覧ください。
 - カレンダーが埋まってないのがちょっと悔しいので小ネタで埋めることにしました。


TL;DR
====
 - OpenCLのin-placeな使い方は、一部のカーネルでは対応していない

テストのFAIL
=====
 - もとはstitchingモジュールのFAILから始まりました。

```
[ RUN      ] ExposureCompensate.SimilarityThreshold
/opencv/modules/stitching/test/test_exposure_compensate.cpp:69: Failure
Expected: (psnr_similarity_mask) > (300), actual: 38.0114 vs 300
[  FAILED  ] ExposureCompensate.SimilarityThreshold (56824 ms)
```

 - かいつまんで説明すると、stitchingモジュールの主な機能は2枚以上の画像を、うまいことつなぎ合わせる(stitch)ことです
 - 今回のテストはその中でも2枚の画像の露光条件が違った場合に2枚の画像の見た目を揃える機能です
 - テストで使う画像によって、類似度が300以上になってほしいのに、何故か38程度にしかなっておらず、degradedな状況に陥っています。

```
Expected: (psnr_similarity_mask) > (300), actual: 38.0114 vs 300
```

- なお、もとの[Issueはこちら](https://github.com/opencv/opencv/issues/19184)、私が直した[PRはこちら](https://github.com/opencv/opencv/pull/19340)です
- 本記事の殆どが、もとのIssueに書いてあることを再編集したものです

https://github.com/opencv/opencv/issues/19184

環境による切り分け
=====
 - 手元にあるOpenCV用のビルドファーム(AWSのJenkinsと、リモートに置いてあるJetson、Raspberry Piなどで構成される)で当該テストを見ると、きれいに「テストがPASSする環境」、「テストがFAILする環境」に二分されました

| テスト結果 | ボード名 |
|:-:|:-|
|FAIL|FireFly RK3399|
|FAIL|ODROID C4|
|FAIL|ODROID N2 plus|
|FAIL|ODROID XU4|
|FAIL|Tinkerboard|
|FAIL|ROCK Pi 4C|
|PASS|Jetson Nano|
|PASS|Jetson TX1|
|PASS|Jetson TX2|
|PASS|Jetson AGX Xavier|
|PASS|Jetson Xavier NX|
|PASS|le potato|
|PASS|Raspberry Pi 3|
|PASS|Raspberry Pi 4|

- 個人的な感想ですが、テスト環境は多ければ多いほど好ましいです
    - デバッグは面で行う。これ大事です。
 - 今回もこの結果を見ただけで原因に心当たりが付きますが、それだとアドベントカレンダーの意味がないので、もうちょっと解説しましょう。
 - こけてる環境はすべてOpenCLを実行できるGPUを持っていました

| ボード名 | SoC | GPU |
|:-|:-|:-|
|firefly-rk3399|RK3399|Mali-T860 OpenCL 1.2|
|odroid-c4|S905X3|Mali-G31 OpenCL 2.0|
|odroid-n2-plus|S922X|Mali-G52 OpenCL 2.0|
|odroid-xu4|Exynos5422|Mali-T628 OpenCL 1.2|
|rock-pi-4c|RK3399|Mali-T860 OpenCL 1.2|
|tinkerboard|RK3288|Mali-T760 OpenCL 1.2|


 - 一方でPASSした奴らはOpenCLを実行できるGPUを持っていません
 - また、OpenCLの実行に関しては[4年前の記事でデバイスや環境変数などについて解説しました](https://qiita.com/tomoaki_teshima/items/a395703284d30fa6e660#%E5%AE%9F%E8%A1%8C%E6%99%82%E3%81%AE%E6%8C%99%E5%8B%95%E3%82%92%E5%A4%89%E3%81%88%E3%82%8B)
 - 当時も紹介した`OPENCV_OPENCL_DEVICE`環境変数を使って、原因がOpenCLカーネルのどこかであることも特定しました

```
$ OPENCV_OPENCL_DEVICE=:gpu ./opencv_test_stitching
(中略)
[  FAILED  ] ExposureCompensate.SimilarityThreshold (56824 ms)

$ OPENCV_OPENCL_DEVICES=disabled ./opencv_test_stitching
(中略)
[       OK ] ExposureCompensate.SimilarityThreshold (93847 ms)
```

 - 上のコマンドを解説すると、冒頭で`OPENCV_OPENCL_DEVICE`環境変数を設定しています
 - 1回目のテストはFAILし、2回目の実行ではテストがPASSします
     - 1回目は`:gpu`という値を環境変数に設定し、これによりOpenCVは内部でGPUがOpenCLに対応しているか確認し、OpenCLがサポートされている環境であればGPUでカーネルを実行します
     - 2回目は`disabled`という値を設定し、この場合はOpenCLのカーネルは呼ばれず、CPU版の実行に限定されます
 - OpenCL版の実行でコケ、CPU版では問題なく動くということなのであとは両者の挙動がどこで発生するか二分探索で絞り込んでいくだけです。

発生場所の特定
====
 - データに違いが発生するところを絞り込み続けると、最終的に[以下の1行](https://github.com/opencv/opencv/blob/63bb2abadab875fc648a572faccafee134f06fc8/modules/stitching/src/exposure_compensate.cpp#L157)まで絞り込むことに成功しました

```exposure_compensate.cpp
if (!similarities_.empty())
{
    CV_Assert(similarity_it != similarities_.end());
    UMat similarity = *similarity_it++;
    bitwise_and(intersect, similarity, intersect); // ←このbitwise_andがおかしい
}
```

 - ここでは配列になっている`similaritiy`の各要素に`intersect`でマスク処理を行っています[^2]
 - ここが原因であることを補強するため、以下のようなコードも試してみました

[^2]: `Similarities`が配列。`similarity`が画像。

```cpp
if (!similarities_.empty())
{
    CV_Assert(similarity_it != similarities_.end());
    UMat similarity = *similarity_it++;
    bool flag = cv::ocl::useOpenCL();   // 現状のフラグを一度退避
    cv::ocl::setUseOpenCL(false);       // OpenCLを一時的にdisable
    bitwise_and(intersect, similarity, intersect); // カーネルを実行
    cv::ocl::setUseOpenCL(flag);        // OpenCLをもとの状態に復帰
}
```

 - 前後に合計3行追記しました
     - `cv::ocl::setUseOpenCL()`APIは`true`か`false`を取り、`false`の場合はプラットフォームの対応状況に関わらず、OpenCLのカーネル実行を取りやめます
     - 前述の`OPENCV_OPENCL_DEVICE=disabled`を実行した場合も、内部的には`cv::ocl::setUseOpenCL(false)`を呼んだことと等価です
     - また、`cv::ocl::getUseOpenCL()`によって現状のOpenCL使用の可否の状況を退避してあるので、このあとの処理に関しても影響が及ばないようになっています
     - 詳しくはdandelion1124先生が書いた[UMatの内部処理](https://qiita.com/dandelion1124/items/fad18a164108158585e3)でも解説されています
 - つまり**このカーネルをGPUで実行する**となにか**予期しない結果になる**、と結論づけられます

後日談というか今回のオチ
====
 - この関数の中を追わないといけないのかと思ってたのですが、OpenCV公式メンバの一人、alalek先生から以下のコメントが付きました

> OpenCL kernels for operations with "in-place" src/dst buffers should be implemented with another kernels code. This is a bug to be fixed in core module.

 - 意訳すると
     - 「OpenCLの"in-place"な操作(src/dstが同一な状況)は別のカーネルとして実装されるべきです。これはcoreモジュール内で修正されるべきバグです」
     - とのことでした
 - もう少しこの"in-place"について説明すると、この`bitwise_and`関数は第1引数が入力、第3引数が出力です
 - このとき、第1引数と第3引数は同じポインタですので、同じ番地を指します
```
bitwise_and(intersect, similarity, intersect);
```
 - さて、OpenCVからOpenCLのカーネルを呼ぶとき、OpenCLへパラメータを渡す必要がありますが、以下のようにポインタは「書き込み専用」「読み込み専用」「読み書き両方許可」の3種類から属性を選んでポインタとともに渡すことができます
 - 以下は実際に到達する`ocl_binary_op`[関数内の実装](https://github.com/opencv/opencv/blob/9dc844a6e142eee62852c15194222acb758a5af3/modules/core/src/arithm.cpp#L111-L114)です

```modules/core/src/arithm.cpp
ocl::KernelArg src1arg = ocl::KernelArg::ReadOnlyNoSize(src1, cn, kercn);
ocl::KernelArg dstarg = haveMask ? ocl::KernelArg::ReadWrite(dst, cn, kercn) :
                                   ocl::KernelArg::WriteOnly(dst, cn, kercn);
```

 - `haveMask`は`false`なので、`dstarg`には`ocl::KernelArg::WriteOnly(dst...)`が代入されます
     - この`WriteOnly`はポインタとその属性、今回は書き込み専用というフラグ、それから幅の情報などを含めて一つのクラスに変換する関数です
 - 一方で、`src1arg`には`ocl::KernelArg::ReadOnlyNoSize(src1...)`という値が設定されます
     - 今度は`ReadOnlyNoSize`という名前の通り、ポインタは読み込み専用として渡されます
 - 一見問題がないように見えますが、**同一ポインタ**が2種類**相反するフラグ**で渡された場合、確かになにか問題が起きそうな気がします
     - ここから先はOpenCLの仕様を調べればわかるかもしれませんが、割愛します
     - 憶測で書けば、「同一ポインタを別フラグで渡された場合の挙動」は実装依存なのではないかと想像します。つまり読み込み専用フラグが優先されるか、書き込み専用フラグが優先されるか、それとも自動的に読み書き両方許可になるのか、そこが実装依存なんだと思います。今回は「読み込み専用」のフラグが優先され、処理は実行されたがメモリに書き戻しが行われず、結果テストFAILにつながったように見えます。(憶測ここまで)
 - というわけで、今回は以下のように迂回策を入れました

```cpp
// in-place operation has an issue. don't remove the swap
// detail https://github.com/opencv/opencv/issues/19184
Mat_<uchar> intersect_updated;
bitwise_and(intersect, similarity, intersect_updated);
std::swap(intersect, intersect_updated);
```
 - `intersect_updated`というバッファを明示的に事前に設け、カーネル実行直後に`std::swap`で参照を入れ替えました[^namedPR]

[^namedPR]: 完全に余談ですが、迂回策な変更を入れる場合、OpenCVのメンテナから、将来のコミットで消えないようにissueのURLとともにコメントを追加するように勧められます。

おわりに
====

 - OpenCVの4.5.1に含まれるバグを紹介しました。
     - バグが混入したのが4.5.1リリース前、バグfixが4.5.2リリース前なのでリリースされたもののうち、4.5.1だけが影響をうけるバグです
     - また、`OPENCV_OPENCL_DEVICE=disabled`を指定すれば迂回できます
 - 約2年前のネタを投稿している時点で結構ネタ切れ感が半端ないですが、カレンダーを埋めるために投稿しました。
 - 明日はとうとう[最終日](https://qiita.com/hon_no_mushi/items/2641e99c5129add092c7)です。