Movatterモバイル変換


[0]ホーム

URL:


LoginSignup
41

Go to list of users who liked

35

Share on X(Twitter)

Share on Facebook

Add to Hatena Bookmark

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

WebAssembly で画像のリサイズ処理をやってみたら JavaScript + Canvas API より遅かった話

Last updated atPosted at 2021-07-27

WebAssembly(WASM) は JavaScript より計算処理が速いという話題がしばしば聞かれます。

では、単純な数値計算ではないけれど、JavaScript を利用した通常のフロントエンド開発だと時間がかかる処理を移植するとどうなるのでしょうか1。本記事ではその一例として、巨大画像(4K~)を指定したピクセル数までリサイズする処理を書いてみました。

リポジトリ:https://github.com/yokra9/wasm-image-resizer

JavaScript + Canvas API でリサイズしてみる

まず、比較のため JavaScript でリサイズ処理を書いておきましょう。とはいえ、TypeScript からコンパイルして生成します。

consturl="./img/sample.jpg";constresp=awaitfetch(url);constb=awaitresp.blob();// JavaScript でリサイズconstblob=awaitresizeImageLegacy(b,512,512);// 画面上に処理結果を表示するdescribeImageFromBlob(blob,"sample");/** * resize image(JS-native) * @param {Blob} file image * @param {number} width width * @param {number} height height * @returns {Promise<Blob>} image */functionresizeImageLegacy(file:Blob,width:number,height:number):Promise<Blob>{returnnewPromise((resolve,reject)=>{constimage=newImage();console.log(`Original:${file.size} Bytes`);constobjectURL=URL.createObjectURL(file);image.onload=()=>{constcanvas=document.createElement('canvas');canvas.width=width;canvas.height=height;constctx=canvas.getContext('2d');if(ctx==null){reject('cannot get context.');return;}ctx.drawImage(image,0,0,image.naturalWidth,image.naturalHeight,0,0,canvas.width,canvas.height);canvas.toBlob((blob)=>{if(blob==null){reject('cannot convert canvas to blob.');return;}console.log(`Resized:${blob.size} Bytes`);resolve(blob);},"image/jpeg",0.8);};image.src=objectURL;});}

流れとしては以下の通りです:

  1. リサイズ後の大きさで Canvas を作成する
  2. BlobURL.createObjectURL() で ObjectURL に変換する
  3. 作成した ObjectURL を image にセットする
  4. Canvas に画像を描画する
  5. HTMLCanvasElement.toBlob()Blob にして返却する

Canvas API を利用しているので、画像のデコード・エンコード処理やリサイズ処理はブラウザの実装任せで、JavaScript として書いているわけではありません2

実行結果(JS)

試行サンプル1 (17446953 Bytes)サンプル2 (14725447 Bytes)サンプル3 ( 6985698 Bytes)
1467.768425.427267.401
2475.222430.402267.401
3484.113424.918243.935
4484.113424.918243.935
5484.113438.421248.328
平均(ms)479.066428.817254.200

16MB の画像を処理するのに 0.5 秒かかっています。メインスレッドがブロックされる秒数として微妙なところですね。console.time() を仕掛けてボトルネックになっている箇所を調べてみます:

実行時間
URL.createObjectURL(): 0.117919921875 msload image from ObjectURL: 29.1630859375 msDocument.createElement(): 0.05419921875 msHTMLCanvasElement.getContext(): 0.070068359375 msCanvasRenderingContext2D.drawImage(): 222.634033203125 msHTMLCanvasElement.toBlob(): 212.616943359375 ms##### resizeImageLegacy #####: 465.1611328125 ms

Canvas に対する操作に要する時間が大きいようです。ところで、この計測は Microsoft Edge 上で実施しており、Canvas に対するハードウェアアクセラレーションが有効になっています。

edge-gpu.jpg

この後作成する WASM 版のコードだとハードウェアアクセラレーションが働かないので、ハードウェアアクセラレーションを切って測定してみます。

edge-nogpu.jpg

Canvas がボトルネックになっているのであれば明確に遅くなっていそうなものですが、実際はどうでしょうか:

試行サンプル1 (17446953 Bytes)サンプル2 (14725447 Bytes)サンプル3 ( 6985698 Bytes)
1367.937331.860189.027
2371.219347.422186.721
3375.982351.710185.914
4389.980356.300187.158
5379.367348.955175.298
平均(ms)376.897347.249184.824
実行時間
URL.createObjectURL(): 0.11572265625 msload image from ObjectURL: 29.06494140625 msDocument.createElement(): 0.072998046875 msHTMLCanvasElement.getContext(): 0.137939453125 msCanvasRenderingContext2D.drawImage(): 161.45703125 msHTMLCanvasElement.toBlob(): 181.51513671875 ms##### resizeImageLegacy #####: 379.366943359375 ms

…なぜか速くなってしまいましたが、やはり Canvas に対する操作に要する時間が大きいようです。メインスレッドのブロッキングを防ぐために WebWorker で別スレッドで動かそうにも、OffscreenCanvas は Chromium 系でしか動作しません。果たして WASM を利用すれば、さらなるパフォーマンスを引き出すことはできるのでしょうか?(タイトルでオチていますが…)

WASM(Rust) でリサイズしてみる

流れとしては以下の通りです3

  1. BlobUint8Array に変換する
  2. Uint8Array とリサイズ後の大きさを WASM に渡す
  3. Uint8Array をバッファ(Vec<u8>)にコピーする
  4. image::load_from_memory() で画像を読み込む
  5. image::resize_exact() でリサイズする
  6. image::write_to() で結果をバッファ(Vec<u8>)に書き込む
  7. Vec<u8>Uint8Array に変換して JS に渡す
  8. Uint8ArrayBlob に変換する

Rust の世界でBlob を扱うのが若干面倒だったのでUint8Array 経由で画像を受け渡ししています。Rust 側では有名なimage クレートを利用して画像の読み込み・リサイズを行っています:

externcrateconsole_error_panic_hook;externcratewasm_bindgen;useimage::*;usejs_sys::*;usewasm_bindgen::prelude::*;# [wasm_bindgen]extern"C"{#[wasm_bindgen(js_namespace=console)]pubfntime(s:&str);#[wasm_bindgen(js_namespace=console)]pubfntimeEnd(s:&str);}# [wasm_bindgen]pubfnresize_image(arr:Uint8Array,width:usize,height:usize,fmt:&str)->Uint8Array{console_error_panic_hook::set_once();// Uint8Array から Vec にコピーするletbuffer=arr.to_vec();// バッファから画像を読み込むletimg=load_from_memory(&buffer).expect("Error occurs at load image from buffer.");// 指定サイズに画像をリサイズするletresized=img.resize_exact(widthasu32,heightasu32,imageops::FilterType::Triangle);// バッファに画像を書き出すletresult=save_to_buffer(resized,fmt);// バッファから Uint8Array を作成Uint8Array::new(&unsafe{Uint8Array::view(&result)}.into())}// バッファに画像を書き出すfnsave_to_buffer(img:DynamicImage,fmt_str:&str)->Vec<u8>{console_error_panic_hook::set_once();letfmt=matchfmt_str{"png"=>ImageOutputFormat::Png,"gif"=>ImageOutputFormat::Gif,"bmp"=>ImageOutputFormat::Bmp,"jpg"=>ImageOutputFormat::Jpeg(80),unsupport=>ImageOutputFormat::Unsupported(String::from(unsupport)),};// バッファを確保して画像を書き出すletmutresult:Vec<u8>=Vec::new();img.write_to(&mutresult,fmt).expect("Error occurs at save image from buffer.");result}

呼び出し側のコードは以下のような形です:

importtype*asWASMfrom"wasm-image-resizer"typeWasm=typeofWASM;// WASM の Shim を動的インポートするconstjs=import("wasm-image-resizer");js.then(asyncwasm=>{consturl="./img/sample.jpg";constresp=awaitfetch(url);constb=awaitresp.blob();// WASMでリサイズconstblob=awaitresizeImageWasm(b,512,512,"jpg",wasm);// 画面上に処理結果を表示するdescribeImageFromBlob(blob,"sample");});/** * resize image(WASM) * @param {Blob} file image * @param {number} width width * @param {number} height height * @param {string} format format * @param {Wasm} wasm WASM  * @returns {Promise<Blob>} image */asyncfunctionresizeImageWasm(file:Blob,width:number,height:number,format:string,wasm:Wasm):Promise<Blob>{console.log(`Original:${file.size} Bytes`);constarr=newUint8Array(awaitfile.arrayBuffer());constresult=wasm.resize_image(arr,width,height,format);constblob=newBlob([result]);console.log(`Resized:${blob.size} Bytes`);returnblob}

実行結果(WASM(Rust))

試行サンプル1 (17446953 Bytes)サンプル2 (14725447 Bytes)サンプル3 ( 6985698 Bytes)
13906.2624072.7972876.642
23903.5394072.7972854.539
33992.0054092.2312918.119
43917.7874073.3642865.451
53917.7874077.8662877.314
平均(ms)3927.4764077.8112878.413

JS の 10 倍遅いです4。こ、こんなはずでは…。今回もconsole.time() を仕掛けてボトルネックになっている箇所を調べてみます:

実行時間
Blob to Uint8Array: 9.274169921875 msUint8Array to Vec<u8>: 33.488037109375 msimage::load_from_memory(): 3275.39013671875 msimage::resize_exact(): 685.47216796875 mssave_to_buffer: 39.794921875 msVec<u8> to Uint8Array: 0.174072265625 msUint8Array to Blob: 0.496826171875 ms##### WebAssembly #####: 4045.272705078125 ms

メイン処理となるimage::resize_exact() だけでも JS より遅いので論外ですが、ボトルネックはimage::load_from_memory() だったようです。5

今回のように WASM(Rust) の世界で速度が出ない事象は、web_sys クレート経由でブラウザ機能を呼び出したり6、呼び出し側やインライン7の JS 関数を呼び出すことで回避できます。

WASM(OpenCV) でリサイズしてみる

他の著名な画像操作ライブラリとしてOpenCV がありますが、現状OpenCV クレートは WASM をターゲットとしたビルドができません。8

そこで、OpenCV.js を試すことにしました。OpenCV.js は OpenCV のサブセットを WASM をターゲットとしてビルドした公式プロジェクトです。こちらも、TypeScript から呼び出してみます。9

typeWindow={cv:any,}declarevarwindow:Window;consturl="./img/sample.jpg";constresp=awaitfetch(url);constb=awaitresp.blob();// OpenCV.js でリサイズconstblob=awaitresizeImageCV(b,512,512,window.cv);// 画面上に処理結果を表示するdescribeImageFromBlob(blob,"sample");/** * resize image(OpenCV) * @param {Blob} file image * @param {number} width width * @param {number} height height * @param {any} cv OpenCV.js  * @returns {Promise<Blob>} image */functionresizeImageCV(file:Blob,width:number,height:number,cv:any):Promise<Blob>{returnnewPromise((resolve,reject)=>{constimage=newImage();console.log(`Original:${file.size} Bytes`);constobjectURL=URL.createObjectURL(file);image.onload=()=>{constcanvas=document.createElement('canvas');canvas.width=width;canvas.height=height;letsrc=cv.imread(image);letdst=newcv.Mat();letdsize=newcv.Size(width,height);cv.resize(src,dst,dsize,0,0,cv.INTER_LINEAR_EXACT);cv.imshow(canvas,dst);src.delete();dst.delete();canvas.toBlob((blob)=>{if(blob==null){reject('cannot convert canvas to blob.');return;}resolve(blob);},"image/jpeg",0.8);};image.src=objectURL;});}

流れは以下の通りです:

  1. リサイズ後の大きさで Canvas を作成する
  2. BlobURL.createObjectURL() で ObjectURL に変換する
  3. 作成した ObjectURL を image にセットする
  4. image 内の画像をバッファに読み込む
  5. OpenCV(WASM)でリサイズしてバッファに書き込む
  6. Canvas にバッファ内の画像を表示する
  7. HTMLCanvasElement.toBlob()Blob にして返却する

Canvas API を利用してはいるものの、リサイズ処理は WASM で行っています。

実行結果(WASM(OpenCV.js))

試行サンプル1 (17446953 Bytes)サンプル2 (14725447 Bytes)サンプル3 ( 6985698 Bytes)
1701.307692.949478.998
2638.878614.146385.925
3715.814683.853416.562
4724.004645.536415.177
5713.559623.795418.676
平均(ms)698.712652.056423.068

Canvas API 程ではないですが、実用的な速度になりました。ボトルネックになっている箇所を調べてみます:

実行時間
URL.createObjectURL(): 0.238037109375 msload image from ObjectURL: 22.555908203125 msDocument.createElement(): 0.084716796875 mscreate dense array: 467.43798828125 mscv::resize(): 12.4599609375 mscv::imshow(): 14.69384765625 msdelete dense array: 0.0068359375 msHTMLCanvasElement.toBlob(): 189.468017578125 ms##### OpenCV.js #####: 707.7109375 ms

リサイズ処理部分と Canvas への描画を合わせても 27 ms と、Canvas API による描画より速度が出ています。一方でバッファ関連がボトルネックになっており、image クレートと共通する問題と言えます。

この結果を見ると、Rust のimage クレートと WASM の相性が特別悪い可能性も考えられますね。Rust 1.54 からは WASM で SIMD が使えるようになる10ので、image クレートの処理が高速化することに期待しています。11

まとめ

重い処理を WASM に持っていけばなんでも早くなるわけではない、という結果になりました。当然ではありますが、問題の箇所が本当に高速化できるのか、コードを書いて実測するべきですね。

残念ながら Canvas API よりも速度は出ませんでしたが、 WASM は WebWorker 上で動作できるので、メインスレッドをブロックしたくない場合には有用でしょう。

また、WASM 側で利用するライブラリの選定やコードの修正により JavaScript より高速になる可能性もあるはずです。本稿を読んだ方で、WASM 版の処理をもっと速くできるよ! という方はコメント欄などで教えてくださると幸いです。


(2021-09-06追記)Rustのimage::load_from_memory遅すぎ問題という記事で、本稿の内容を検証していただいております。「一ヶ月ほど調査、試行錯誤して」いただいたということで、本当にありがたい限りです。image::load_from_memory() の仕様とphoton を参考に再実装を行ったところ、実用的な速度が出る範囲まで高速化できたということです。ここまでお読みになられた方は、ぜひ上記の記事にも目を通していただければと思います。

参考リンク

  1. ブラウザ上で利用可能な各種Web_API(CanvasAPI等)は利用するものとします。従って、JavaScriptの計算処理によって画像のデコード・エンコード処理やリサイズ処理を行うという意味ではありません。

  2. ちなみにCanvasのSpecはECMAScriptではなくHTML_Living_Standardにあります。

  3. 他にもWebAssembly.Memory()でWASMインスタンスのメモリを掴み生ポインタを触る方法もあります。試してみましたが、複雑な割に速度は大きく変わりませんでしたので紹介しません。

  4. wasm-pack build --releaseでリリースフラグを立てた状態でこの速度です。え…私のコード、遅すぎ?!

  5. load_from_memoryという名前ですが、実際にはフォーマットに応じたデコード処理も含まれています。

  6. https://rustwasm.github.io/docs/wasm-bindgen/examples/2d-canvas.html

  7. https://rustwasm.github.io/docs/wasm-bindgen/reference/js-snippets.html

  8. https://github.com/twistedfall/opencv-rust/issues/124

  9. anyが残るコードになっているのは無念ですが、公式には型定義ファイルが見つかりませんでした。実際のwindow.cvJSON.stringfy()してquicktypeに通す手も考えましたが、WASMコードが含まれているとJSON化できませんでした。実プロジェクトで使いたい場合は、仕様理解のためにも型定義ファイルを作った方がいいと思います。

  10. https://tech-blog.optim.co.jp/entry/2021/07/30/080000#arch_wasm

  11. ただしRust_1.54+image_0.23.14でビルドしたところimage::resize_exact()の実行速度は10ms程度早くなりましたが、ボトルネックとなるimage::load_from_memory()ではほぼ改善が見られませんでした。もともとムラは大きかったため、誤差の範囲に収まっているように見えます。

41

Go to list of users who liked

35
2

Go to list of comments

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41

Go to list of users who liked

35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?


[8]ページ先頭

©2009-2025 Movatter.jp