BlurHashについて
Webサイトにおける画像のプレースホルダーとして提案されているものです。画像の読み込みには時間がかかるので、読み込みが完了するまでの間に仮に表示させておく画像がプレースホルダーです。
ASCII文字30文字程度という非常に少ない情報量で保存できるので、HTMLに埋め込んでおくことも可能です。それでいて復元後の画像は色調をある程度再現しているので、クライアント側で画像を復元して表示することにより、真っ白な画像を出すよりもUXを向上できるでしょう。見た目がぼかしフィルターをかけたようなのでBlurとついています。
例を示しましょう。以下の画像をBlurHashに変換し、BlurHashからプレースホルダー画像を生成したものです。この色調をASCII文字28文字で表現しています。
↓encode
LnIX{#t6Ntf6yGs:X7j?I@juxGj[
(BlurHash)
↓decode
特にWebアプリで重宝されるアルゴリズムなので、PHPでも使いたいケースが出てくるかと思います。PHPの実装も公開されていますが、このPHP実装によるエンコード処理が非常に遅いのです。
C言語の実装 vs PHPの実装
- C言語版: https://github.com/woltapp/blurhash/tree/master/C
- PHP版: https://github.com/kornrunner/php-blurhash
実験条件は以下とします。
- Ubuntu 22.04.4 LTS
- CPU: Celeron(R) CPU 847 @ 1.10GHz(10年前くらいのNUCです)
- GCC 11.4.0
- PHP 8.1.2
コマンド
# C言語
./blurhash_encoder 4 3 sample.jpg
# PHP
php blurhash_encoder.php 4 3 sample.jpg
PHPのスクリプトは以下のようにしています。上記「PHP版」のリポジトリのReadme「Encoding with GD」のサンプルほぼそのままで、autoloadを使わないように書いた点が主な差分です。
<?php
require_once('php-blurhash/src/Blurhash.php');
require_once('php-blurhash/src/Color.php');
require_once('php-blurhash/src/DC.php');
require_once('php-blurhash/src/AC.php');
require_once('php-blurhash/src/Base83.php');
use kornrunner\Blurhash\Blurhash;
$components_x = int($argv[1]);
$components_y = int($argv[2]);
$file = $argv[3];
$image = imagecreatefromstring(file_get_contents($file));
$width = imagesx($image);
$height = imagesy($image);
$pixels = [];
for ($y = 0; $y < $height; ++$y) {
$row = [];
for ($x = 0; $x < $width; ++$x) {
$index = imagecolorat($image, $x, $y);
$colors = imagecolorsforindex($image, $index);
$row[] = [$colors['red'], $colors['green'], $colors['blue']];
}
$pixels[] = $row;
}
$blurhash = Blurhash::encode($pixels, $components_x, $components_y);
echo $blurhash;
結果
timeコマンドで処理時間を計測し、結果を比較します。
入力画像サイズは640×427pxです。
C implementation
real 0m0.693s
user 0m0.689s
sys 0m0.004s
PHP implementation
real 0m4.884s
user 0m4.668s
sys 0m0.216s
PHP実装では、なんとC言語実装の約7倍の処理時間がかかることが分かりました。
PHP実装の何が遅い?
見るからに↓は遅そうですよね。
for ($y = 0; $y < $height; ++$y) {
$row = [];
for ($x = 0; $x < $width; ++$x) {
$index = imagecolorat($image, $x, $y);
$colors = imagecolorsforindex($image, $index);
$row[] = [$colors['red'], $colors['green'], $colors['blue']];
}
$pixels[] = $row;
}
1ピクセルずつ画素値を取得して配列に入れることを繰り返しています。配列といっても、C言語のように同じデータ型の値が規則的に並んでいることを仮定していません。どんなデータ型でも入れられる代わりに実行速度は遅いです。ループ処理についても、インタプリタ言語で典型的に遅くなるパターンのような気がします。
BlurHashの計算部分の実装もPure PHP(全部の処理がPHPで書いてある)であり、このようなループ処理が随所に書かれています。
PHPのWebアプリからでも高速に実行する方法
ここで思いつく改善策として、C言語の実装をPHPの拡張モジュールとしてコンパイルすることが挙げられます。やってみましょう。
これ以降の内容は、主に以下の各ページの内容をもとにしています。
準備
開発用のパッケージをインストールします。おそらく以下のパッケージがあればよいのではないかと思います。(不要なものがあるかもしれません)
sudo apt install autoconf automake libtool m4 php-dev
PHPのソースコードをチェックアウトします。今回は実行環境がPHP 8.1.2なので、合わせておきます。
git clone git@github.com:php/php-src.git
cd php-src
git checkout php-8.1.2
拡張モジュールのひな形を作成する
cd ext
./ext_skel.php --ext blurhash
(snip)
Success. The extension is now ready to be compiled. To do so, use the
following steps:
cd /path/to/php-src/ext/blurhash
phpize
./configure
make
(snip)
blurhash
というディレクトリができています。拡張モジュールとして作成する場合、ここに置いておく必要はないので、別の場所に移動しておきます。先ほどのコマンドで指示された通りに拡張モジュールをコンパイルします。
mv blurhash /path/to/work/php-blurhash
cd /path/to/work/php-blurhash
phpize
./configure
make
うまくいけば、これで modules/blurhash.so
という拡張モジュールが生成されます。
拡張モジュールが読み込めるかテストします。
## 以下 (1) (2) のいずれかを行う
# (1) php.ini に設定を記入して読み込む
echo "extension=modules/blurhash.so" > php.ini
php -c php.ini -m
# (2) コマンドラインで設定を直接指定する
php -d extension=modules/blurhash.so -m
(1) (2) どちらの方法でも同じ結果になりますが、[PHP Modules]
のリストの中に blurhash
が現れたら成功です。
実際に呼び出せるか試します。(1) のようにphp.iniが作成されているものとして進めます。
php -c php.ini -r 'test1();'
> The extension test is loaded and working!
php -c php.ini -r 'echo test2("world\n");'
> Hello world
拡張モジュールの実装を眺める
このひな形では、test1, test2 の2つの関数が定義されています。実装は blurhash.c
に書かれています。コメントは私が追加したものです。
PHP_FUNCTION(test1)
{
ZEND_PARSE_PARAMETERS_NONE(); // 引数のない関数
php_printf("The extension %s is loaded and working!\r\n", "blurhash"); // stdoutに出力
}
PHP_FUNCTION(test2)
{
char *var = "World"; // 引数を受けるための変数、デフォルト値が指定されている
size_t var_len = sizeof("World") - 1;
zend_string *retval;
ZEND_PARSE_PARAMETERS_START(0, 1) // 0個以上1個以下の引数を受け取る
Z_PARAM_OPTIONAL // これ以降の引数はオプション引数
Z_PARAM_STRING(var, var_len) // 文字列引数、varで文字列本体を、var_lenで長さを受け取る
ZEND_PARSE_PARAMETERS_END();
retval = strpprintf(0, "Hello %s", var); // sprintfのような処理をして文字列 (zend_string) を返す
RETURN_STR(retval); // 戻り値として文字列を返す
}
だいたい以下のような流れになっていることが分かります。
- 引数を受け取るための変数を宣言
- 引数の個数と型を宣言
- 実際の処理
- RETURN
以下のページにもう少し詳しい話が書いてあります。
https://www.zend.com/resources/php-extensions/adding-new-functionality
https://www.zend.com/resources/php-extensions/basic-php-structures
BlurHashの実装を書く
拡張モジュールの実装は、オリジナルのC言語の実装に倣いましょう。
必要なファイルは common.h
, encode.c
, encode.h
, stb_image.h
の4つです。このうち、ヘッダファイル3つは拡張モジュールの作業場所の include
ディレクトリに置き、encode.c
はとりあえず拡張モジュールの作業場所のルートに置いておきます1。なお、前述のC言語の実装はチェックアウトされているものとします。
cd /path/to/blurhash/C # C言語の実装が置かれている場所
cp encode.c /path/to/work/php-blurhash
cp common.h encode.h stb_image.h /path/to/work/php-blurhash/include
encode.c
をソースファイルとして追加します。
- PHP_NEW_EXTENSION(blurhash, blurhash.c, $ext_shared)
+ PHP_NEW_EXTENSION(blurhash, blurhash.c encode.c, $ext_shared)
blurhash.c
にはヘッダファイルの記述を追加します。
#include "php.h"
#include "ext/standard/info.h"
#include "php_blurhash.h"
#include "blurhash_arginfo.h"
+#include "encode.h"
+
+#define STB_IMAGE_IMPLEMENTATION
+#include "stb_image.h"
次に、PHPからの関数の呼び出し方ですが、オリジナルの encode_stb.c
を参考に、今回は blurHashForFile
という関数名で呼び出せるようにしてみます。プロトタイプは以下のようにします。
blurHashForFile(int $xComponents, int $yComponents, string $filename)
blurhash.c
に PHP_FUNCTION
を追加し、実装を書きます。
PHP_FUNCTION(blurHashForFile)
{
zend_long xComponents, yComponents;
char *filename = "";
size_t filename_len = 0;
const char *blurHash;
unsigned char *data = NULL;
int width, height, channels;
ZEND_PARSE_PARAMETERS_START(3, 3) // 引数を3個受け取る
Z_PARAM_LONG(xComponents)
Z_PARAM_LONG(yComponents)
Z_PARAM_STRING(filename, filename_len) // ファイル名をchar*として受け取るとともに、その長さを受け取る(今回は長さ情報は使用しない)
ZEND_PARSE_PARAMETERS_END();
data = stbi_load(filename, &width, &height, &channels, 3);
if(!data) {
blurHash = "";
} else {
blurHash = blurHashForPixels(xComponents, yComponents, width, height, data, width * 3);
}
stbi_image_free(data); // 画像データのメモリを解放
RETURN_STRING(blurHash); // BlurHashを文字列として返却
}
blurhash_arginfo.h
にも blurHashForFile
の情報を登録します。
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test1, 0, 0, IS_VOID, 0)
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_test2, 0, 0, IS_STRING, 0)
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, str, IS_STRING, 0, "\"\"")
ZEND_END_ARG_INFO()
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_blurHashForFile, 0, 3, IS_STRING, 0)
+ ZEND_ARG_INFO(0, xComponents)
+ ZEND_ARG_INFO(0, yComponents)
+ ZEND_ARG_INFO(0, filename)
+ZEND_END_ARG_INFO()
ZEND_FUNCTION(test1);
ZEND_FUNCTION(test2);
+ZEND_FUNCTION(blurHashForFile);
static const zend_function_entry ext_functions[] = {
ZEND_FE(test1, arginfo_test1)
ZEND_FE(test2, arginfo_test2)
+ ZEND_FE(blurHashForFile, arginfo_blurHashForFile)
ZEND_FE_END
};
再度コンパイルすれば、blurHashForFile
関数が使えるようになるはずです。
phpize
./configure
make
動作確認
<?php
$components_x = (int)$argv[1];
$components_y = (int)$argv[2];
$filename = $argv[3];
$blurhash = blurHashForFile($components_x, $components_y, $filename);
echo $blurhash;
実行します。
php -c php.ini blurhash_encoder_module.php 4 3 sample.jpg
BlurHash値がターミナルに出力されたら成功です。
C言語実装と同じBlurHashが得られることをご確認ください。
復元してみる
C言語実装をコンパイルすると得られる blurhash_decoder
コマンドを使って確認します。
先ほど得られたBlurHashの値、元の画像サイズ、出力先画像ファイル (png) を指定します。
./blurhash_decoder 'LnIX{#t6Ntf6yGs:X7j?I@juxGj[' 640 427 decoded.png
気になる速度を測定
time php -c php.ini blurhash_encoder_module.php 4 3 sample.jpg
>LnIX{#t6Ntf6yGs:X7j?I@juxGj[
>real 0m1.067s
>user 0m1.039s
>sys 0m0.028s
高速化されたのはBlurHashの計算部分だけですし、モジュール呼び出しのオーバーヘッドもあるでしょうから、すべてC言語で書かれた場合に比べると劣ります。しかし、Pure PHPの実装と比べると計算時間は1/4以下になりました。
まとめ
BlurHashの計算という題材でPHPの拡張モジュールを作り、処理の高速化ができることを確認しました。ドキュメントを詳細に理解しようとすると大変なのですが、基本的な関数呼び出しだけであれば見よう見まねでも案外作れることが分かりました。
他の機能については、使いたくなったら調べればよいでしょう。前述のZendのページが参考になりそうです。