空雲 Blog

Eye catch[WebAssembly]Emscriptenによる画像最適化ライブラリの作り方

publication: 2024/09/05
update:2024/09/05

はじめに

Emscripten は C/C++のコードを WebAssembly に変換することが出来ます。また、標準で SDL2 をサポートしているため、画像処理ライブラリの作成に適しています。今回は、Emscripten を使って画像最適化ライブラリを作成する方法を紹介します。

画像最適化ライブラリは png や jpeg など比較的圧縮率が低いフォーマットから、webp や avif などの高圧縮率のフォーマットに変換したり、サイズの調整する機能を提供します。

作成したものがどんなものかは以下のリンクから確認できます。

https://www.npmjs.com/package/wasm-image-optimization
https://www.npmjs.com/package/wasm-image-optimization-avif

WebAssembly 化したときの利点は、ブラウザ上で動作させたり、Cloudflare や Deno のサーバ上の無料枠で動作させることが出来る点です。

環境構築

ローカル環境に Emscripten をインストールして設定を行うのはダルいので、Docker を使って環境構築を行います。

  • Dockerfile

1FROM emscripten/emsdk
2WORKDIR /app

環境構築が完了しました。簡単ですね。

必要なパッケージのインストール

Emscripten は SDL2 によって png,jpeg,gif などは標準で使えるのですが、webp や avif などは追加で必要なパッケージを取ってくる必要があります。また、jpeg の exif 情報を使って回転情報を取得するためにも対応するパッケージが必要です。

  • Dockerfile

1FROM emscripten/emsdk
2WORKDIR /app
3RUN apt-get update && apt-get install -y dh-autoreconf ninja-build yasm &&\
4 git clone https://github.com/webmproject/libwebp &&\
5 git clone https://github.com/AOMediaCodec/libavif &&\
6 git clone https://github.com/libexif/libexif &&\
7 ln -s /app/libwebp/src/webp /emsdk/upstream/lib/clang/20/include/webp &&\
8 ln -s /app/libavif/include/avif /emsdk/upstream/lib/clang/20/include/avif

ということで、必要なパッケージをインストールしました。ビルドに必要なツール類も入れておきます。さらにパッケージの include パスをシンボリックリンクで Emscripten のヘッダーファイルに追加します。

  • docker-compose.yml

1version: "3.7"
2services:
3 emcc:
4 container_name: wasm-image-optimization
5 build:
6 context: .
7 dockerfile: ./Dockerfile
8 volumes:
9 - app:/app
10 - cache:/emsdk/upstream/emscripten/cache
11 - ../Makefile:/app/Makefile
12 - ../src:/app/src
13 - ../dist:/app/dist
14volumes:
15 app:
16 cache:

ホスト側の src ディレクトリの中に画像最適化ライブラリのソースコードを配置し、dist ディレクトリにビルド結果を出力出来るようにします。

Makefile の作成

webp と avif と exif のライブラリをビルドし、各パッケージをリンクできるようにします。さらに出力時に wasm と併用して使う.js ファイルを esm と cjs 形式で出力するように、二回ビルドを行います。

  • Makefile

ビルドが通るように試行錯誤してできたものは、控えめに言ってカオスです。

1SHELL=/bin/bash
2WORKDIR=work
3DISTDIR=dist
4ESMDIR=$(DISTDIR)/esm
5WORKERSDIR=$(DISTDIR)/workers
6LIBDIR=libavif/ext
7
8TARGET_ESM_BASE = $(notdir $(basename src/libImage.cpp))
9TARGET_ESM = $(ESMDIR)/$(TARGET_ESM_BASE).js
10TARGET_WORKERS = $(WORKERSDIR)/$(TARGET_ESM_BASE).js
11
12CFLAGS = -O3 -msimd128 \
13 -Ilibwebp -Ilibwebp/src -Ilibavif/include -Ilibavif/third_party/libyuv/include -Ilibavif/ext/aom \
14 -Ilibexif \
15 -DAVIF_CODEC_AOM_ENCODE -DAVIF_CODEC_AOM_DECODE -DAVIF_CODEC_AOM=LOCAL
16
17CFLAGS_ASM = --bind \
18 -s WASM=1 -s ALLOW_MEMORY_GROWTH=1 -s ENVIRONMENT=web -s DYNAMIC_EXECUTION=0 -s MODULARIZE=1 \
19 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s USE_SDL_GFX=2 \
20 -s SDL2_IMAGE_FORMATS='["png","jpg","webp","svg","avif"]'
21
22WEBP_SOURCES := $(wildcard libwebp/src/dsp/*.c) \
23 $(wildcard libwebp/src/enc/*.c) \
24 $(wildcard libwebp/src/utils/*.c) \
25 $(wildcard libwebp/src/dec/*.c) \
26 $(wildcard libwebp/sharpyuv/*.c)
27AVIF_SOURCES := libavif/src/alpha.c \
28 libavif/src/avif.c \
29 libavif/src/colr.c \
30 libavif/src/colrconvert.c \
31 libavif/src/diag.c \
32 libavif/src/exif.c \
33 libavif/src/io.c \
34 libavif/src/mem.c \
35 libavif/src/obu.c \
36 libavif/src/rawdata.c \
37 libavif/src/read.c \
38 libavif/src/reformat.c \
39 libavif/src/reformat_libsharpyuv.c \
40 libavif/src/reformat_libyuv.c \
41 libavif/src/scale.c \
42 libavif/src/stream.c \
43 libavif/src/utils.c \
44 libavif/src/write.c \
45 libavif/src/codec_aom.c \
46 libavif/third_party/libyuv/source/scale.c \
47 libavif/third_party/libyuv/source/scale_common.c \
48 libavif/third_party/libyuv/source/scale_any.c \
49 libavif/third_party/libyuv/source/row_common.c \
50 libavif/third_party/libyuv/source/planar_functions.c
51
52EXIF_SOURCES := $(wildcard libexif/libexif/*.c) \
53 $(wildcard libexif/libexif/canon/*.c) \
54 $(wildcard libexif/libexif/fuji/*.c) \
55 $(wildcard libexif/libexif/olympus/*.c) \
56 $(wildcard libexif/libexif/pentax/*.c)
57
58WEBP_OBJECTS := $(WEBP_SOURCES:.c=.o)
59AVIF_OBJECTS := $(AVIF_SOURCES:.c=.o)
60EXIF_OBJECTS := $(EXIF_SOURCES:.c=.o)
61
62.PHONY: all esm workers clean
63
64all: esm workers
65
66$(WEBP_OBJECTS) $(AVIF_OBJECTS): %.o: %.c | $(LIBDIR)/aom_build/libaom.a
67 @emcc $(CFLAGS) -c $< -o $@
68
69$(LIBDIR)/aom_build/libaom.a:
70 @echo Building aom...
71 @cd $(LIBDIR) && ./aom.cmd && mkdir aom_build && cd aom_build && \
72 emcmake cmake ../aom \
73 -DENABLE_CCACHE=1 \
74 -DAOM_TARGET_CPU=generic \
75 -DENABLE_DOCS=0 \
76 -DENABLE_TESTS=0 \
77 -DCONFIG_ACCOUNTING=1 \
78 -DCONFIG_INSPECTION=1 \
79 -DCONFIG_MULTITHREAD=0 \
80 -DCONFIG_RUNTIME_CPU_DETECT=0 \
81 -DCONFIG_WEBM_IO=0 \
82 -DCMAKE_BUILD_TYPE=Release && \
83 make aom
84
85$(WORKDIR):
86 @mkdir -p $(WORKDIR)
87
88$(WORKDIR)/webp.a: $(WORKDIR) $(WEBP_OBJECTS)
89 @emar rcs $@ $(WEBP_OBJECTS)
90
91$(WORKDIR)/avif.a: $(WORKDIR) $(AVIF_OBJECTS)
92 @emar rcs $@ $(AVIF_OBJECTS)
93
94$(WORKDIR)/libexif.a: $(EXIF_SOURCES)
95 @cd libexif && autoreconf -i && emconfigure ./configure && cd libexif && emmake make
96 @emar rcs $@ $(EXIF_OBJECTS)
97
98$(ESMDIR) $(WORKERSDIR):
99 @mkdir -p $@
100
101esm: $(TARGET_ESM)
102
103$(TARGET_ESM): src/libImage.cpp $(WORKDIR)/webp.a $(WORKDIR)/avif.a $(WORKDIR)/libexif.a $(LIBDIR)/aom_build/libaom.a | $(ESMDIR)
104 emcc $(CFLAGS) -o $@ $^ \
105 $(CFLAGS_ASM) -s EXPORT_ES6=1
106
107workers: $(TARGET_WORKERS)
108
109$(TARGET_WORKERS): src/libImage.cpp $(WORKDIR)/webp.a $(WORKDIR)/avif.a $(WORKDIR)/libexif.a $(LIBDIR)/aom_build/libaom.a | $(WORKERSDIR)
110 emcc $(CFLAGS) -o $@ $^ \
111 $(CFLAGS_ASM)
112 @rm $(WORKERSDIR)/$(TARGET_ESM_BASE).wasm
113
114clean:
115 @echo Cleaning up...
116 @rm -rf $(WORKDIR) $(LIBDIR)/aom_build $(DISTDIR)/esm $(DISTDIR)/workers

画像最適化ライブラリのソースを作成

集めた力を結集し、C++で画像最適化ライブラリを作成します。

  • src/libImage.cpp

1#include <emscripten.h>
2#include <emscripten/bind.h>
3#include <emscripten/val.h>
4#include <webp/encode.h>
5#include <SDL2/SDL2_rotozoom.h>
6#include <SDL2/SDL_image.h>
7#include <SDL2/SDL.h>
8#include <libexif/exif-data.h>
9#include <avif/avif.h>
10
11using namespace emscripten;
12
13EM_JS(void, js_console_log, (const char *str), {
14 console.log(UTF8ToString(str));
15});
16
17class MemoryRW
18{
19public:
20 MemoryRW()
21 {
22 m_rw = SDL_AllocRW();
23 m_rw->hidden.unknown.data1 = &m_buffer;
24 m_rw->write = MemWrite;
25 m_rw->close = MemClose;
26 }
27 ~MemoryRW()
28 {
29 SDL_FreeRW(m_rw);
30 }
31 operator SDL_RWops *() const { return m_rw; }
32 size_t size() const { return m_buffer.size(); }
33 const uint8_t *data() const { return m_buffer.data(); }
34
35protected:
36 static size_t MemWrite(SDL_RWops *context, const void *ptr, size_t size, size_t num)
37 {
38 std::vector<uint8_t> *buffer = (std::vector<uint8_t> *)context->hidden.unknown.data1;
39 const uint8_t *bytes = (const uint8_t *)ptr;
40 buffer->insert(buffer->end(), bytes, bytes + size * num);
41 return num;
42 }
43 static int MemClose(SDL_RWops *context)
44 {
45 return 0;
46 }
47
48private:
49 SDL_RWops *m_rw;
50 std::vector<uint8_t> m_buffer;
51};
52
53int getOrientation(std::string img)
54{
55 int orientation = 1;
56 ExifData *ed = exif_data_new_from_data((const unsigned char *)img.c_str(), img.size());
57 if (!ed)
58 {
59 return orientation;
60 }
61 ExifEntry *entry = exif_content_get_entry(ed->ifd[EXIF_IFD_0], EXIF_TAG_ORIENTATION);
62 if (entry)
63 {
64 orientation = exif_get_short(entry->data, exif_data_get_byte_order(entry->parent->parent));
65 }
66 exif_data_unref(ed);
67 return orientation;
68}
69
70val optimize(std::string img_in, float width, float height, float quality, std::string format)
71{
72 int orientation = getOrientation(img_in);
73
74 SDL_RWops *rw = SDL_RWFromConstMem(img_in.c_str(), img_in.size());
75 if (!rw)
76 {
77 return val::null();
78 }
79
80 SDL_Surface *srcSurface = IMG_Load_RW(rw, 1);
81 SDL_FreeRW(rw);
82 if (!srcSurface)
83 {
84 return val::null();
85 }
86
87 int srcWidth = srcSurface->w;
88 int srcHeight = srcSurface->h;
89 if (srcWidth == 0 || srcHeight == 0)
90 {
91 SDL_FreeSurface(srcSurface);
92 return val::null();
93 }
94
95 int outWidth = width ? width : srcWidth;
96 int outHeight = height ? height : srcHeight;
97 float aspectSrc = static_cast<float>(srcWidth) / srcHeight;
98 float aspectDest = outWidth / outHeight;
99
100 if (aspectSrc > aspectDest)
101 {
102 outHeight = outWidth / aspectSrc;
103 }
104 else
105 {
106 outWidth = outHeight * aspectSrc;
107 }
108
109 SDL_Surface *newSurface = zoomSurface(srcSurface, (float)outWidth / srcWidth, (float)outHeight / srcHeight, SMOOTHING_ON);
110 SDL_FreeSurface(srcSurface);
111 if (!newSurface)
112 {
113 return val::null();
114 }
115
116 if (orientation > 1)
117 {
118 double angle = 0;
119 double x = 1;
120 double y = 1;
121 switch (orientation)
122 {
123 case 2:
124 x = -1.0;
125 break;
126 case 3:
127 angle = 180.0;
128 break;
129 case 4:
130 y = -1.0;
131 break;
132 case 5:
133 angle = 90.0;
134 x = -1.0;
135 break;
136 case 6:
137 angle = 270.0;
138 break;
139 case 7:
140 angle = 270.0;
141 x = -1.0;
142 break;
143 case 8:
144 angle = 90.0;
145 break;
146 }
147 SDL_Surface *rotatedSurface = rotozoomSurfaceXY(newSurface, angle, x, y, SMOOTHING_ON);
148 SDL_FreeSurface(newSurface);
149 newSurface = rotatedSurface;
150 }
151
152 if (format == "png" || format == "jpeg")
153 {
154 MemoryRW memoryRW;
155 if (format == "png")
156 {
157 IMG_SavePNG_RW(newSurface, memoryRW, 1);
158 }
159 else
160 {
161 IMG_SaveJPG_RW(newSurface, memoryRW, 1, quality);
162 }
163 SDL_FreeSurface(newSurface);
164 val result = val::null();
165 if (memoryRW.size())
166 {
167 result = val::global("Uint8Array").new_(typed_memory_view(memoryRW.size(), memoryRW.data()));
168 }
169 return result;
170 }
171 else
172 {
173 if (newSurface->format->format != SDL_PIXELFORMAT_RGBA32)
174 {
175 SDL_Surface *convertedSurface = SDL_ConvertSurfaceFormat(newSurface, SDL_PIXELFORMAT_RGBA32, 0);
176 SDL_FreeSurface(newSurface);
177 if (convertedSurface == NULL)
178 {
179 return val::null();
180 }
181 newSurface = convertedSurface;
182 }
183 if (format == "webp")
184 {
185 uint8_t *img_out;
186 val result = val::null();
187 int width = newSurface->w;
188 int height = newSurface->h;
189 int stride = width * 4;
190 size_t size = WebPEncodeRGBA(reinterpret_cast<uint8_t *>(newSurface->pixels), width, height, stride, quality, &img_out);
191 if (size > 0 && img_out)
192 {
193 result = val::global("Uint8Array").new_(typed_memory_view(size, img_out));
194 }
195 WebPFree(img_out);
196 SDL_FreeSurface(newSurface);
197 return result;
198 }
199 else
200 {
201 int width = newSurface->w;
202 int height = newSurface->h;
203 avifImage *image = avifImageCreate(width, height, 8, AVIF_PIXEL_FORMAT_YUV444);
204
205 avifRGBImage rgb;
206 avifRGBImageSetDefaults(&rgb, image);
207 rgb.depth = 8;
208 rgb.format = AVIF_RGB_FORMAT_RGBA;
209 rgb.pixels = (uint8_t *)newSurface->pixels;
210 rgb.rowBytes = width * 4;
211
212 if (avifImageRGBToYUV(image, &rgb) != AVIF_RESULT_OK)
213 {
214 return val::null();
215 }
216 avifEncoder *encoder = avifEncoderCreate();
217 encoder->quality = (int)((quality) / 100 * 63);
218 encoder->speed = 6;
219
220 avifRWData raw = AVIF_DATA_EMPTY;
221
222 avifResult encodeResult = avifEncoderWrite(encoder, image, &raw);
223 avifEncoderDestroy(encoder);
224 avifImageDestroy(image);
225 if (encodeResult != AVIF_RESULT_OK)
226 {
227 return val::null();
228 }
229 val result = val::global("Uint8Array").new_(typed_memory_view(raw.size, raw.data));
230 avifRWDataFree(&raw);
231 return result;
232 }
233 }
234}
235
236EMSCRIPTEN_BINDINGS(my_module)
237{
238 function("optimize", &optimize);
239}
240

ソースの内容は送られてきた画像を展開し回転情報を処理して、指定されたエンコーダーに変換を投げます。特別なロジックはいらないので、C++に慣れていない人でも多少試行錯誤すれば書けると思います。

ビルド

1docker compose -f docker/docker-compose.yml run --build --rm emcc make -j

これでビルドが行われ、wasm ファイルが出力されます。

まとめ

Emscripten を使って画像最適化ライブラリを作成する方法を紹介しました。最も面倒くさいのは、必要なライブラリを集めてリンク可能な状態にする部分です。便利なエコシステムなどありません。Makefile は地獄です。何をどうすればいいのか、カンが要求されます。

肝心の自分で組むコードの部分はただのツギハギです。スキルもへったくれもいらないというのがお分かりいただけると思います。