Intel GPUでJPEGデコード・推論

# 動機など
  
大量の画像ファイルに対して画像認識とかをしたいときがありますよね。
 - 画像のデータセットに前処理をしたい
 - 自分の撮った写真を分類したい
などなど....


普通に実装しようと思ったら
```mermaid
graph TB
    B[ディレクトリをイテレートする]
    B-->C[画像ファイルをデコード]
    C-->D["NumPy配列(に準ずるもの)に変換"]
    D-->E[ONNXRuntimeとかで推論]
    E-->F[モデルの出力を整形・後処理]
    F-->G[結果を出力]
    G-->B

```

という風にやるのが典型だと思います。
実際にやってみると、JPEGのデコードがボトルネックになってGPUを使いきれないということがわかってきます。  
JPEGのデコードって重い処理なんですよね...  
NVIDIA GPUなら[`torchvision.io.decode_jpeg`](https://pytorch.org/vision/main/generated/torchvision.io.decode_jpeg.html)という関数があって、これでNVJPEGを呼べます。
これによって、CPUはファイルを読んでGPUに投げ、(場合によっては)後処理をするだけで画像認識ができます。  

で、タイトル通り、今回はこれをIntel GPUでもやりたいというわけです。

# QSVの実装をする
[Intel® Video Processing Library](https://www.intel.com/content/www/us/en/developer/tools/vpl/overview.html) というものがあって、ffmpegのQSVはこれを叩いているので、これをC++から使って、[nanobind](https://github.com/wjakob/nanobind)でPythonのライブラリを自作すると良さそうです。
全体像は[ソースコード](https://github.com/yayoimizuha/intel_jpeg_decoder_plugin/blob/master/test_ext.cpp)を読んでいただきたいのですが、ざっと
```C++
MFXCreateSession //ライブラリの初期化
MFXVideoDECODE_DecodeHeader //JPEGのヘッダ解析
MFXVideoDECODE_Init //JPEGのヘッダの情報に基づいて、Media Engineを初期化
MFXVideoDECODE_DecodeFrameAsync //ビットストリームを投げてエンコード開始
```
という感じで処理を行います。
一つ問題があって、JPEGを毎回デコードするごとにMedia Engineの初期化が必要です。
半年前くらいまでめちゃくちゃ遅かったのですが、ドライバを最新に更新したら、ある程度は使える速度になっていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/de0379b9-8d96-7c1d-fcdc-580f274cfde8.png)
これがJPEGの処理1回分をズームしたVTuneのキャプチャなのですが、[ID3D11VideoDevice::CreateVideoDecoder](https://learn.microsoft.com/ja-jp/windows/win32/api/d3d11/nf-d3d11-id3d11videodevice-createvideodecoder)が時間を食っていることが分かります。一般に動画をデコードする場合、動画の最初のフレームを処理する際に1度だけMedia Engineを`ID3D11VideoDevice::CreateVideoDecoder`で初期化してやればよいですが、今回はJPEGファイルを処理するため、毎ファイルごとにハフマンテーブルや解像度が異なっているので、初期化してやる必要があります。  
実際、`ID3D11VideoDevice::CreateVideoDecoder`のドキュメントを読むと、引数の`D3D11_VIDEO_DECODER_DESC`のメンバに画像の高さと幅を要求しています。VPLライブラリがバックエンドでDirectX11を使うのをやめないとこれ以上速くならなさそうですが......

# `nanobind`でPythonに渡す
### C++側
`nanobind`は、Pythonのライブラリを作るのを簡単にしてくれるライブラリで、よく知られた`pybind11`よりもシンプルに使えるところがウリらしいです。
`nanobind`はNumPyとかDLPack対応のデータ形式を渡せるらしいですが、自分がやってみた限りだと`bad cast`という実行時エラーが出てどうしようもなかったので、ポインタ渡しをすることにしました。`nanobind`にポインタ渡しという概念は無いので、とりあえず数値に変換して渡してやることにします。C++側では`reinterpret_cast<intptr_t>(ptr)`するだけです。

### Python側
Python側では、`ctypes`でガチャガチャやることによってC/C++のポインタを読むことができます。
```py
y_arr = numpy.frombuffer((ctypes.c_uint8 * (pitch_h * pitch)).from_address(ptr), dtype=numpy.uint8,
                         count=pitch_h * pitch)
print(f"{y_arr=}")
uv_arr = numpy.frombuffer((ctypes.c_uint8 * (int(pitch_h * 1.5) * pitch)).from_address(ptr),
                          dtype=numpy.uint8,
                          count=int(pitch_h / 2) * pitch, offset=pitch_h * pitch)
print(f"{uv_arr=}")

```
`(ctypes.c_uint8 * size).from_address(ptr)`でポインタが読めるようです。それを`numpy.frombuffer`で読み込みます。この演算子オーバーロードはさすがにヤバいんじゃないかと思いますが、サンプルにそう書いてあったのでしょうがないです。DLLは同一のメモリ空間なんだなあって実感しますね。  

<br>
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/8058b7fa-d9a7-da22-7f3c-8f04ed8d1682.png)

NV12といって、`Y`(輝度情報)を本来のピクセル分、`U`(青-輝度)を4分の1、`V`(赤-輝度)を4分の1ずつ持たせて情報量を削った形式で返されるので、このままでは画像認識モデルに突っ込めないです。そこで`RGB`に変換する必要があります。

なお、図では、Y1からY48まで連続して得られているように見えますが、実際は一列ごとに右端がpaddingされています。そもそもJPEGが8x8ブロックでMCUを構成しているから、大体8の倍数になっています。
よって、そのままメモリ配列をNumPy配列にしてpyplot.imshowすると、せん断されたような表示になってしまいます。つまり、NumPyで言うところの行列のサイズとストライドが異なっている状態ですね。
NumPyには、`numpy.lib._stride_tricks_impl.as_strided`という、ストライドを手動で指定できるありがたい関数があるので、
```py
y_plane = as_strided(y_arr, (pitch_h, pitch_w), (pitch, 1))
uv_plane = as_strided(uv_arr, (int(pitch_h / 2), int(pitch_w / 2), 2), (pitch, 2, 1))
```
といった感じで読み込めます。`uv_plane`は、それぞれ縦横2倍に`repeat(2, axis=0).repeat(2, axis=1)`することで、`y_plane`とサイズを合わせます。

|Y|U|V|[元画像](https://pixabay.com/ja/photos/%E5%8F%8B%E4%BA%BA%E5%90%8C%E5%A3%AB-%E4%BC%9A%E7%A4%BE-%E6%89%93%E3%81%A1%E5%90%88%E3%82%8F%E3%81%9B%E4%BC%9A%E8%AD%B0-4385686/)|
|---|---|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/43308993-f388-fdc6-ecdf-4d8c034e1c3b.png">|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/8338b07e-62c6-519e-e5f7-c789fdc4e49b.png">|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/2f3fd342-fc34-7ac4-faa7-8acca6d35b4c.png)|![friends-4385686.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/e31e3a4f-73b7-7fc0-d3e3-a09a3839c9e7.jpeg)|


```math
\begin{bmatrix}
R \\
G \\
B \\
\end{bmatrix}
=
\begin{bmatrix}
1 & 0 & 1.402 \\
1 & -0.344136 & -0.714136 \\
1 & 1.772 & 0 \\
\end{bmatrix}
\begin{bmatrix}
Y \\
U - 128 \\
V - 128 \\
\end{bmatrix}
```
という行列の積でRGBに変換できます。
自分の扱ってるJPEG画像をざっと調べたところ、大体`Rec.601`という色空間だったので、上記の式の変換行列を用いればいいことが分かりました。
```py
ycbcr_mat = yuv_plane.transpose((1, 2, 0)) - [0, 128, 128]
# print(ycbcr_mat)
transform_matrix = numpy.array([
    [1, 0, 1.402],
    [1, -0.344136, -0.714136],
    [1, 1.772, 0]
])
rgb_plane = (numpy.clip(numpy.dot(ycbcr_mat, transform_matrix.T), 0, 255).astype(numpy.uint8))
```
これでOK.
ここまで出来たらあとはOpenVINOで推論しておしまいです。


<br><br><br>
# 実行結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/de9779b1-52c7-62cb-a880-007cae6da474.png)
平常時の負荷の目安
| 1 | 2 | 3 |
|:-:|:-:|:-:|
| Intel QSV | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/0b34a9fb-41f1-f8b0-2c09-4f085095808b.png) | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/4def2101-08c0-6577-86fd-f66875a3145b.png) |
| Pillow |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/9e67e154-f485-14a1-6277-894f3500db31.png) | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/455447/6e5f9bfd-3683-ac84-0444-17fd252eb01f.png) |


結論としては、推論速度を保ちつつCPUの負荷はかなり抑えられる！といった感じでしょうか。
※突っ込んでくる人がいると思うので書いておきますが、Pillow-SIMDはPillowと同等の速度でした。


# 結論
Intel GPUしか搭載してない環境(Intelのラップトップとか)においては、ハードウェアにオフロードして低コストでそれなりの推論ができると思うが、デスクトップ環境では普通にNVIDIA GPU使うのが速くて便利だと思います。

真似したい変人さんは[ここ](https://github.com/yayoimizuha/helloproject-ai/blob/master/test_script/qsv_jpeg_decode.py)と[ここ](https://github.com/yayoimizuha/intel_jpeg_decoder_plugin/blob/master/test_ext.cpp)にソースあるんで読んでみるなり、僕に聞くなりしてください。
あとは、最近`Intel® Video Processing Library`に、デコードした画像をOpenCLのメモリとしてエクスポートする機能が生えたので、この機能を使えば一度もCPU側にデータを返さずにOpenVINOで推論できるんじゃないか？と画策しています...(時間があったらやるかも)