bimg (libvips Goバインディング) でサムネイル画像作成を高速化する

はじめに

こんにちは、Webエンジニアの濱田裕太 (@yuuta) です。

Quipperが開発・運用している スタディサプリ のプロダクトでは、バックエンドの一部をGoで実装しています。そのサムネイル画像作成で利用する画像処理ライブラリを imaging から bimg (libvips のGoバインディング) に変更したことで、高速化・省メモリ化を実現しました。本エントリでは、それによって得られた知見を共有します。

1. どのくらい高速化・省メモリ化したか

1-1. 検証コード

imaging, bimgそれぞれで入力画像から幅200pxのサムネイル画像を作成し、ファイル保存するコードです。性能検証目的のため、エラー処理は割愛しています。

// 幅200pxサムネイル作成 - imaging

package main

import (
    "os"
    "github.com/disintegration/imaging"
)

func main() {
    inputFilePath := os.Args[1]
    outputFilePath := os.Args[2]

    img, _ := imaging.Open(inputFilePath, imaging.AutoOrientation(true))
    thumb := imaging.Resize(img, 200, 0, imaging.Lanczos)
    imaging.Save(thumb, outputFilePath, imaging.JPEGQuality(85))
}
// 幅200pxサムネイル作成 - bimg

package main

import (
    "os"
    "github.com/h2non/bimg"
)

func main() {
    inputFilePath := os.Args[1]
    outputFilePath := os.Args[2]

    img, _ := bimg.Read(inputFilePath)
    thumb, _ := bimg.Resize(img, bimg.Options{Width: 200, StripMetadata: true, Quality: 85})
    bimg.Write(outputFilePath, thumb)
}

imagingでは画像読み込み時に imaging.AutoOrientation(true) のオプションを指定することで、Exif情報から画像の向きを判定して自動補正します。bimgでは、デフォルトで画像の向きが自動補正されます。また、bimgでは画像処理時に bimg.Options{StripMetadata: true} を指定することで、Exif情報などのメタデータが取り除かれます。imagingでは、デフォルトでExif情報などのメタデータが取り除かれます。

リサイズ処理では、imaging, bimgともに Lanczos3 というアルゴリズムが適用されます。JPEG保存品質 (Quality) は、双方で 85 を明示的に指定しています。この理由は「3-3. JPEG保存品質のデフォルト値」で説明します。

1-2. 処理時間

以下は検証コードを各入力画像で実行した結果です。入力画像は、8MP (800万画素), 12MP, 16MP, 20MP のカメラ画像を想定したJPEGファイル、および FullHD (1080p), 2K (1440p), 4K (2160p) のディスプレイスクリーンショット画像を想定したPNGファイルです。

processing-time

結果を見ると、imaging (青色) に比べてbimg (橙色) の処理時間が圧倒的に少なくなっています。特に JPEG画像での差が顕著 ですが、この理由は「2-3. JPEGスケーリングデコード」で説明します。

興味深い点として、imagingではCPUコア数を 1 -> 8 に増やすことで処理時間が減っていますが、bimgでは全く変わっていません。この理由は「2-1. libvipsの画像処理アーキテクチャ」で説明しますが、200px程度のサムネイル画像を作成する場合、bimgでは1コアあれば十分 といえるかも知れません。

1-3. メモリ使用量

以下は「1-2. 処理時間」での各入力画像を処理したときのメモリ使用量です。

memory-usage

結果を見ると、imaging (青色) に比べてbimg (橙色) のメモリ使用量が圧倒的に少なくなっています。こちらは JPEG, PNG画像ともに顕著な差 が出ています。

さらに結果を注意深く見ると、bimgのメモリ使用量は画像のファイルサイズとほぼ同じ値 になっています。一方、imagingのメモリ使用量は画像の画素数と相関があり、例えばPNG画像では画素数 (幅×高さ) の約4倍の値となっています。この理由は「2-1. libvipsの画像処理アーキテクチャ」で説明します。

2. なぜ高速化・省メモリ化されるのか

大きく3つの理由があると考えます。

2-1. libvipsの画像処理アーキテクチャ

bimg は、高速・省メモリな画像処理ライブラリである libvips のGoバインディングです。libvipsは、他の画像処理ライブラリとは異なる独自のアーキテクチャを備えています。詳細は libvips公式サイトの解説ページ に委ねますが、ざっくり説明すると以下のような特徴を持っています。

  • (1) 画像の読み込み時点では、画素データ配列への展開 (デコード) を行わない
  • (2) 画像処理時に、画像の形式や処理内容に応じて画像データを水平方向に逐次細かく分割し、分割された小領域を順次/並列で処理していく
  • (3) 画像処理完了時点で、指定された画像形式 (JPEG, PNGなど) でのエンコードが完了している

(1), (3) では画像ファイルサイズのメモリしか使用せず、(2) での使用メモリは画像データを細かく分割しているため僅かなものになります。そのため、bimg (libvips) ではメモリ使用量が画像ファイルサイズとほぼ同じ値 で済みます。一方、imaging では画像読み込み時に全画素のデータを配列展開するため、例えばPNGでは画素数 (幅x高さ) × 4バイト (赤色・青色・緑色・不透明度の各要素1バイト) のメモリが使用されます。また、(2) の画像処理では、水平分割により画像の読み込み (デコード)・処理・書き込み (エンコード) を並行して行えます。bimg (libvips) が AutoOrientation, StripMetadata, Quality などのオプションを画像読み書き時でなく画像処理時に指定しているのは、このアーキテクチャのためです。このように libvipsでは、画像形式や画像処理の内容に応じて水平分割された小領域を逐次処理することで、高速化・省メモリ化の両方を実現 しています。

興味深い点として、bimgでは スレッドセーフ目的でlibvipsの並列処理数を 1 に固定している ようです。「1-2. 処理時間」においてbimgでの処理時間がCPUコア数を 1 -> 8 に増やしても同じだったのはこのためです。bimgでのlibvips並列処理数は環境変数 VIPS_CONCURRENCY によって変更できますが、幅200pxのサムネイル画像作成では VIPS_CONCURRENCY=8 にしても処理時間は同じでした。幅を10倍の2000pxにしたら差が出たので、幅200px程度のサムネイル画像を作成する場合、bimgでは1コアあれば十分 ということかも知れません。

2-2. SIMD対応

これは bimg (libvips) 自体の機能ではなく、その依存ライブラリである libjpeg-turbo などが対応している機能になります。

SIMD (Single Instruction / Multiple Data) とはその名の通り、一つの命令を複数のデータに同時適用する処理方式です。これはいわゆるマルチコアプロセッサでの複数命令の並列実行 (MIMD; Multiple Instruction / Multiple Data) とは異なります。SIMDは同一の演算を多くのデータに適用するような処理に向いているので、画像処理にはまさにうってつけです。CPUによって対応命令や性能に差はあるものの、昨今のほとんどのCPUはSIMDの機構を備えていると思います。

SIMDに対応している画像処理ライブラリを利用することで、画像データ読み込み (デコード)・書き込み (エンコード) などの処理が高速化 されます。他言語では、例えば PythonPillow-SIMDSIMDに対応しています。

2-3. JPEGスケーリングデコード

こちらも bimg (libvips) 自体ではなくその依存ライブラリである libjpeglibjpeg-turbo が提供する機能ですが、JPEG画像の縮小処理において非常に強力な機能となります。

JPEG画像では8x8画素単位で画像の周波数成分が記録されており、デコード時にはこの周波数成分を合成することで画素データ配列に変換します。このとき、低周波数成分のみを用いることで、元画像の 1/2, 1/4, 1/8 サイズでデコードすることができます。このときの各画素は、それぞれ 2x2, 4x4, 8x8 画素ブロックの平均をとったような感じになります。

scaling-decode

画像縮小処理において 縮小後のサイズが元画像に比べて十分小さくなる場合、元画像をフルサイズでデコードする必要はありません。例えば幅4032pxのJPEG画像を幅200pxに縮小する場合、まずは1/8サイズの幅504pxでデコードし、そこから Lanczos3 などのアルゴリズムで200pxに縮小すれば、画像品質をそこまで低下させずに処理時間とメモリ使用量を大幅に減らすことができます。これがJPEGスケーリングデコードの仕組みであり、「1-2. 処理時間」においてPNG画像よりもJPEG画像の処理時間が圧倒的に少なくなっているのはこのためです。

同様の機能は例えば ImageMagickのsize hinting でも提供されていますが、画像読み込み時に明示的に値を指定する必要があります。一方、bimgでは画像処理時に画像の形式や処理内容に応じて適切なscaling factorを内部計算してくれる ので、ライブラリ利用者が特に意識することはありません。このようにJPEGスケーリングデコードは、画像読み込み時ではなく画像処理時に画素データ配列への展開を行うlibvipsの画像処理アーキテクチャと非常に相性が良い機能であるといえます。また、この機能は WebP の画像形式でも使えるようです。

3. 運用時の留意点

bimg (libvips) を利用したプロダクトの運用に当たり、いくつかの留意点があります。

3-1. libvipsのパッケージサイズ

bimgはlibvipsのGoバインディングであるため、事前にlibvipsの導入が必要となります。

libvips-dev を apt / apk などのパッケージマネージャーでインストールすると、様々な画像形式を処理するための依存ライブラリが大量にインストール されます。いわゆる「全部入り」ですが、パッケージサイズはかなり大きくなります。プロダクトによっては一部の画像形式しか使わないこともあるので、その場合はディスク容量やDockerイメージサイズが無駄に増えてしまいます。

これに対し、プロダクトで必要な画像形式のみに関する依存ライブラリを用いてビルド (make) することで、ディスクやDockerイメージの容量を節約 できます。例えばJPEG画像 (Exif情報付き) とPNG画像のみを扱うのであれば、libjpeg-turbo-dev, libexif-dev, libpng-dev があれば最低限のことができます。インストール/ビルド方法や依存ライブラリ (Optional dependencies) については、libvips公式サイトのインストールページ が参考になります。

3-2. libvipsのオペレーションキャッシュ

libvipsには オペレーションキャッシュ (Operation Cache) と呼ばれる仕組みが存在します。画像に対する操作内容と処理結果のバイナリデータを保持しておき、同一画像に対して同一操作が要求された場合はキャッシュの結果を返します。デフォルトでは、過去1000回の操作内容および100MBまでの処理結果をキャッシュするようです。

この仕組みは、例えば Photoshop などの画像編集アプリケーションにおいては有効であると思います。しかし、不特定多数のユーザーが様々な画像をワンショットで送信するようなWebアプリケーションとはあまり相性が良くない と考えます。この場合、サーバのメモリを無駄に消費するデメリットの方が大きいため、キャッシュを無効化する判断も必要になります。

bimgでlibvipsのオペレーションキャッシュを無効化するには、以下のように2つの設定値を 0 にします。これにより、サーバーサイドでのメモリ消費量を抑え、libvipsの利点である省メモリ性能を最大限に活かすことができます。

bimg.VipsCacheSetMax(0)
bimg.VipsCacheSetMaxMem(0)

3-3. JPEG保存品質のデフォルト値

JPEG画像は非可逆圧縮形式でエンコードされるため、保存時に画質が劣化します。この劣化をどれくらい抑えるかがJPEG保存品質 (Quality) と呼ばれるパラメータです。値の範囲は1~100で、JPEG保存時の画質とファイルサイズに影響します。ここで留意すべき点は、JPEG保存品質のデフォルト値が画像処理ライブラリによってマチマチであること です。例えば imaging 1.6.2 では 95bimg 1.1.5 では 75 がデフォルト値となっています。

以下のグラフは、性能検証で用いたJPEG画像 (4032 x 3024) で保存品質を70~100に変えたときのファイルサイズです。結果を見ると、品質が 85 までは画像ファイルサイズの増加は緩やかですが、90 を超えたあたりで急激に増加しています。この増加具合は画像の内容によって変わってきますが、概ねの傾向は同様になります。

jpeg-quality

GoogleのWebサイトパフォーマンスに関するガイドライン によると、JPEG保存品質は 85 にすることで画像品質と画像サイズのバランスが良くなるようです。これを基準とした場合、imagingのデフォルト値 95 は大きすぎ、bimgのデフォルト値 75 は小さすぎ ます。JPEG保存品質の望ましい値はプロダクトの要件によっても変わると思うので一概にはいえませんが、ライブラリのデフォルト値を意識しておかないと、意図せずJPEGのファイルサイズが肥大化したり画像品質が悪くなったりする ので注意が必要です。

おわりに

以上、libvips のGoバインディングである bimg を利用したサムネイル画像作成の高速化・省メモリ化に関する知見を共有しました。libvipsはGo以外でもRuby, Python, PHP, Node.jsなど 様々なプログラミング言語に対応 しており、プロダクトでは Rails6.0からのActiveStoregeバックエンドAWS Serverless Image Handler のバックエンドである Sharp などでも採用されています。

libvips は ImageMagickOpenCV のように豊富で複雑な画像処理/画像認識の機能を持っている訳ではありませんが、画像の縮小や切り抜きなど比較的シンプルな処理を高速・省メモリで実現するのには向いているライブラリだと思います。

画像処理ライブラリにはそれぞれに一長一短があるので、利用するプロダクトの特性や要件に応じて最適な選択ができると良いと思います。本エントリがその一助となれば幸いです。