OpenCVにおける画像ファイル入出力の仕組み(imwrite編)

この記事は[OpenCV Advent Calendar 2022](https://qiita.com/advent-calendar/2022/opencv)の5日目の記事です。
他の記事は[目次](https://qiita.com/advent-calendar/2022/opencv)にまとめられています。

## TL;DR
この記事では、 OpenCVの画像出力の流れをまとめています。

## はじめに

### 簡単に自己紹介？

画像処理系の組み込みエンジニアです。OpenCV communityにもちょくちょくコメントしています。以後、よろしくお願いします！

### そういえば、imread()/imwrite()とか画像コーデック対応、勉強してないな。

それは、1件のissueから始まった。

[OpenCVのissue](https://github.com/opencv/opencv/issues)を見ていたら、TGA format対応へのfeature requestが来ていた。

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

今回、画像フォーマット対応（入出力）の仕組みを勉強して行きたい。例えば、 **上手く画像データが読み込めない、書き込めない** とかあっても中身を知っていれば安心ですね！！

記事が長くなるので、「読み込み」の前編と、「書き出し」の後編の、２回に分けて説明します。

## Step 1. CMakeList.txt

<details><summary>CMakeList.txtの説明は補足なので、興味がある方はクリックして展開して下さい imwrite()とimread()で同じ内容です</summary><div>

### CODECに対応するマクロ

cmakeコマンドで、CODECを有効にする設定を追加したい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89434/33d867f6-c03b-ea45-f09a-1a05e5cfcd88.png)

コーデックに関するマクロ/設定値がいくつかある。

- WITH_xxxx: xxxxを有効化する。
- BUILD_xxxx: 3rdpartyフォルダ以下のコードをコンパイル。なければ、shared library参照。
- HAVE_xxxx: xxxxライブラリが存在する

### WITH_xxxx(+BUILD_xxxx) -> HAVE_xxxx

HAVE_xxxxは以下のようなコードで導出される。

https://github.com/opencv/opencv/blob/da4ac6b7eff2e8869567e4faaff73312f9e1ef57/cmake/OpenCVFindLibsGrfmt.cmake#L40-L84

```cmake:OpenCVFindLibsGrfmt.cmake

# --- libjpeg (optional) ---
if(WITH_JPEG)
  if(BUILD_JPEG)
    ocv_clear_vars(JPEG_FOUND)
  else()
    ocv_clear_internal_cache_vars(JPEG_LIBRARY JPEG_INCLUDE_DIR)
    include(FindJPEG)
  endif()

　＜略＞
  set(HAVE_JPEG YES)
endif()

```


CMakeList.txtは[こちら](https://github.com/opencv/opencv/blob/b0dc474160e389b9c9045da5db49d03ae17c6a6b/CMakeLists.txt#L306-L308)

```cmake:CMakeLists.txt
OCV_OPTION(WITH_JPEG "Include JPEG support" ON
  VISIBLE_IF TRUE
  VERIFY HAVE_JPEG)
```

### CMake.実行時のログに表示

いつも、cmakeコマンドで表示されるアレを実現するところです。

```bash
   Media I/O:
     ZLib:                        /usr/lib/x86_64-linux-gnu/libz.so (ver 1.2.11)
     JPEG:                        build-libjpeg-turbo (ver 2.1.3-62)
       SIMD Support Request:      YES
       SIMD Support:              YES
     WEBP:                        build (ver encoder: 0x020f)
     PNG:                         /usr/lib/x86_64-linux-gnu/libpng.so (ver 1.6.38)
     TIFF:                        build (ver 42 - 4.2.0)
```

CMakeList.txtは[こちら](https://github.com/opencv/opencv/blob/b0dc474160e389b9c9045da5db49d03ae17c6a6b/CMakeLists.txt#L1315-L1323)。

```cmake:CMakeLists.txt
if(WITH_JPEG OR HAVE_JPEG)
  if(NOT HAVE_JPEG)
    status("    JPEG:" NO)
  elseif(BUILD_JPEG)
    status("    JPEG:" "build-${JPEG_LIBRARY} (ver ${JPEG_LIB_VERSION})")
  else()
    status("    JPEG:" "${JPEG_LIBRARY} (ver ${JPEG_LIB_VERSION})")
  endif()
endif()
```

</div></details>

## Step 2. 各種Codecの登録と呼び出し（省略）

### Codecの登録
`imgcodecs`の中にある、`struct ImageCodecInitializer`構造体で、Encoder/Decoderを生成し、`decoders`や`encoders`で管理している。これにより、画像フォーマットを柔軟に追加することができる。

ソースコードは、[こちら](https://github.com/opencv/opencv/blob/b0dc474160e389b9c9045da5db49d03ae17c6a6b/modules/imgcodecs/src/loadsave.cpp#L121-L199)。

```c++:loadsave.cpp
struct ImageCodecInitializer
{
    /**
     * Default Constructor for the ImageCodeInitializer
    */
    ImageCodecInitializer()
    {
        /// BMP Support
        decoders.push_back( makePtr<BmpDecoder>() );
        encoders.push_back( makePtr<BmpEncoder>() );
 :
```
### Codec登録周辺をUMLで表現すると...

`BaseImageDecoder`を基底クラスとして、各Codecを派生することで共通I/Fを持った実装を実現している。

```mermaid
classDiagram
  direction LR

  class BaseImageDecoder {
    <<abstract>> 
    +newDecoder()* ImageDecoder
  }

  class TiffDecoder {
    +newDecoder() ImageDecoder
  }
  class TiffEncoder {
    +newEncoder() ImageEncoder
  }
  class PngDecoder {
    +newDecoder() ImageDecoder
  }
  class PngEncoder {
    +newEncoder() ImageEncoder
  }

  class ImageCodecInitializer {
    -vector＜BaseImageDecoder＞ decoders
    -vector＜BaseImageEncoder＞ encoders
    +ImageCodecInitializer()
  }

  BaseImageDecoder <|.. TiffDecoder  
  BaseImageDecoder <|.. PngDecoder  

  BaseImageEncoder <|.. TiffEncoder  
  BaseImageEncoder <|.. PngEncoder  

  class BaseImageEncoder {
    <<abstract>> 
    +newDecoder()* ImageEncoder
  }

  ImageCodecInitializer "1" *-- "0..*" BaseImageDecoder
  ImageCodecInitializer "1" *-- "0..*" BaseImageEncoder
```

### imwrite()からの呼び出しシーケンス

アプリケーションがimwrite()を呼び出したときの、大まかなシーケンスは以下となる。


```mermaid
sequenceDiagram
    actor app as User Application
    participant imwrite as imgcodec<br/>loadsave.cpp<br/>imread()
    participant findEncoder as imgcodec<br/>imread.cpp<br/>findEncoder()

    app->>+imwrite : imwrite( filaname, mat, params)
    imwrite->>+findEncoder:findEncoder( filename )
    loop All decorders
      findEncoder ->> +encoder : getDescription()
      encoder ->> -findEncoder : return description
    end
    findEncoder-->-imwrite : return encoder
    imwrite->>encoder : setDestination( filename )
    imwrite->>+encoder : write ( mat, params )
    encoder->>encoder : paramの解析
    encoder->>encoder : matの出力
    encoder-->>-imwrite : return true/false

    imwrite-->>-app:return mat
```

## Step 3. findEncoder()

`imwrite()`の第一引数はファイル名である。この拡張子を見て、どの画像フォーマットを使うのかを選択する。例えば、JPEG画像であれば`.jpg`、png画像であれば`.png`が付与される。なお、ファイル名が存在しないバッファへの圧縮の場合でも、`.jpg`という形で識別子を指定する必要がある（そして、`jpg`だけを指定して上手く動かない、までが既定路線である）。

さて、getDescription()だが、これはBaseImageEncoder()クラスで、m_descrptionを返す関数と定義されている。

| Encoder | m_description                  |
|:-------:|:------------------------------:|
| BMP     | Windows bitmap (\*.bmp;\*.dib) |
| JPEG    | JPEG files (\*.jpeg;\*.jpg;*.jpe) |
| TIFF    | TIFF Files (\*.tiff;\*.tif) |


https://github.com/opencv/opencv/blob/b0dc474160e389b9c9045da5db49d03ae17c6a6b/modules/imgcodecs/src/loadsave.cpp#L300-L325

```c++
    ImageCodecInitializer& codecs = getCodecs();
    for( size_t i = 0; i < codecs.encoders.size(); i++ )
    {
        String description = codecs.encoders[i]->getDescription();
        const char* descr = strchr( description.c_str(), '(' );


        while( descr )
        {
            descr = strchr( descr + 1, '.' );
            if( !descr )
                break;
            int j = 0;
            for( descr++; j < len && isalnum(descr[j]) ; j++ )
            {
                int c1 = tolower(ext[j]);
                int c2 = tolower(descr[j]);
                if( c1 != c2 )
                    break;
            }
            if( j == len && !isalnum(descr[j]))
                return codecs.encoders[i]->newEncoder();
            descr += j;
        }
    }


    return ImageEncoder();
```

## Step 4. encoder->write()

### paramの解析

encoderには、imwriteflagを使ってオプション(param)を指定できる。

https://docs.opencv.org/4.6.0/d8/d6a/group__imgcodecs__flags.html#ga292d81be8d76901bff7988d18d2b42ac

この`param`は、`vector<int>`形式で管理されていて、IDとVALUEをペアで扱う必要がある。例えばこんな感じで使う。

```c++
  std::vector<int> params;
  params.push_back( IMWRITE_PNG_COMPRESSION );
  params.push_back( 95 );
  params.push_back( IMWRITE_PNG_STRATEGY );
  params.push_back( IMWRITE_PNG_STRATEGY_DEFAULT );
```

これに対して、２個ずつループを回して、IDとVALUEを取り込む。

https://github.com/opencv/opencv/blob/b0dc474160e389b9c9045da5db49d03ae17c6a6b/modules/imgcodecs/src/grfmt_png.cpp#L392-L409

```c++
                for( size_t i = 0; i < params.size(); i += 2 )
                {
                    if( params[i] == IMWRITE_PNG_COMPRESSION )
                    {
                        compression_strategy = IMWRITE_PNG_STRATEGY_DEFAULT; // Default strategy
                        compression_level = params[i+1];
                        compression_level = MIN(MAX(compression_level, 0), Z_BEST_COMPRESSION);
                    }
                    if( params[i] == IMWRITE_PNG_STRATEGY )
                    {
                        compression_strategy = params[i+1];
                        compression_strategy = MIN(MAX(compression_strategy, 0), Z_FIXED);
                    }
                    if( params[i] == IMWRITE_PNG_BILEVEL )
                    {
                        isBilevel = params[i+1] != 0;
                    }
                }
```

### matの出力

さて、あとは画像データを、各ライブラリに対して供給すれば画像ファイルが作成できる。これは各ライブラリの都合に合わせるとなる。

png出力だと、このあたりが実質コア部分となる。

```c++
                    buffer.allocate(height);
                    for( y = 0; y < height; y++ )
                        buffer[y] = img.data + y*img.step;


                    png_write_image( png_ptr, buffer.data() );
                    png_write_end( png_ptr, info_ptr );
```

マルチページTIFFなどもこのコードで共通化されている、流石ですね！！ということで、技術紹介はここまでと。

## まとめ

お忙しい中、ご精読、ありがとうございました！！

10年越えてもその構造を大きく直す必要がない、ということは、当時からきちんと考え、設計されていたという事なのかもしれませんね！

このシステムであれば、新たな画像フォーマットを追加する事も難しい話ではありません。

次回は"OpenCVにおける画像ファイル入出力の仕組み(imread編)" の予定です。よろしくお願いします！