先日、crates.io にて Rust 製の画像処理ツール「rusimg」を crates.io にて公開しました。BMP, JPEG, PNG, WebP の各種主要フォーマットに対応し、フォーマット間での画像変換・リサイズ・圧縮(クオリティの指定)・トリミング・コマンドライン上での画像表示に対応しています。
※インストールには Rust のインストールが必須です
開発者である私自身、開発しながら既に長いこと普段使いしており、例えば当ブログに貼っている画像はほぼすべて JPEG や PNG 形式から WebP 形式に rusimg で変換したものです。よって動作確認はバッチリです。
とは言ってもこちらのツール、当ブログでは初出ではなく…もう2年も前になるんですが、開発途中のツールの紹介記事として2023年7月に一度言及しています。
「一旦ベータ版として近日中に公開しようかなと思っております」なんて書いてたのに、ベータ版として公開することすらなく2年も放置していました。申し訳ございません…
開発背景
きっかけ
ブログ記事を書いてて、フォルダ内の画像を WebP に一括に変換できるツールがあったら便利だろうなと思ったのが発端です。
WebP が軽量かつ高画質であることは知っているけれど、普及したのはここ数年の話(Safari が対応したのがつい3年前)なので、対応している便利なツールがまだ少ない。
例えば Windows 標準のフォトアプリやペイントアプリでは、WebP を閲覧することはできますが保存ができません。ffmpeg という最強のツールもありますが、1ファイルごとの変換にしか対応していないためファイルを一括変換するには for 文なりスクリプトを書かなければなりません。
というわけで、WebP に対応していて、ついでに個人的に主要だと思っている BMP, JPEG, PNG 形式にも対応した、自分用の画像処理専用のツールがほしい、というか作りたいと思ったので作りました。
(え、ImageMagick?知らない子ですね)
なぜ Rust なのか?
画像処理を進めるうえでのメモリ安全性の保証がなされるという点もありますが、一番の理由は依存関係の導入や公開が楽だからです。
rusimg はバックエンドの処理として、画像フォーマットごとに Image、jpeg-encoder、oxipng、webp の各クレートに対応しています。ビルドの際に各ライブラリを導入して…なんてしなくとも、そして各 OS に向けてクロスコンパイルしなくとも、cargo publish
で公開しておけばユーザ側の環境で全自動でビルドしてくれるシステムが開発者側の視点からするとあまりにも便利です。
なぜ公開が遅れたのか?
基本的な機能は2年前に完成していたのですが、その後も細かい部分を作り込みすぎてキリが無くなったからです。あとはリファクタリングとかライブラリ化とかを延々とやっていました。公開する基準を一旦を定めないと駄目ですね。
でも一番の理由は単に公開するのが面倒だったからです。cargo がドキュメントを自動で作ってくれるとはいえ、README くらいはちゃんとしたものを用意しなければ…なんて先延ばしにしてたら2年経っていました。
使用ライブラリなど
クレート | 用途 |
---|---|
Image | 画像処理全般, DynamicImage, BMP 出力 |
jpeg-encoder | JPEG 出力、圧縮 |
oxipng | PNG 出力、圧縮 |
webp | WebP 出力、圧縮 |
clap | コマンドライン引数のパース |
regex | ワイルドカードの解釈 |
viuer | コマンドライン上での画像表示 |
glob | 画像ファイル検索 |
colored | コマンドライン上でのカラー文字出力 |
tokio, features | マルチスレッド処理 |
機能
前回の記事とほぼ変わりませんが、細かい機能だけ増やしました。太字が前回の記事からの新機能です。
- 画像操作機能
- 画像変換機能 (-c, –convert)
- jpeg, png, webp, bmp の間で相互に画像変換
- 画像圧縮機能 (-q, –quality)
- 画像変換時に、画像の品質を 0~100 % の間で調整可能
- ただし bmp は圧縮不可(そもそも圧縮形式でないため)
- トリミング機能 (-t, –trim)
- 画像の一部を切り出して出力する
- XxY+WxH の形式で指定(例:0x0+100x100)
- リサイズ機能 (-r, –resize)
- 画像を任意のサイズにリサイズ
- パーセンテージで指定
- グレースケール化機能 (-g, –grayscale)
- 画像をモノクロにする
- 画像変換機能 (-c, –convert)
- ファイル操作機能
- ファイル入力 (-i, –input)
- ディレクトリ内の画像を一括変換できる
- ワイルドカードでのファイル指定にも対応
- もちろん、1画像ずつ指定することも可能
- 省略した場合、カレントディレクトリから画像を検索
- ファイル出力 (-o, –output)
- 出力先のファイル名、あるいはディレクトリを指定
- 拡張子の有無でファイルかディレクトリかを判断
- 出力先ディレクトリが存在しない場合は作成
- 省略した場合、元々の画像ファイルに上書き、あるいは拡張子を変えて保存(画像変換した場合)
- ファイル名末尾追記機能 (-a, –append)
- 出力ファイル名の末尾に特定の文字列を追加する(例:
-a "_new"
)
- 出力ファイル名の末尾に特定の文字列を追加する(例:
- 二重拡張子 (-d, –double-extension)
- 画像変換してファイル出力するとき、
<ファイル名>.<元の拡張子>,<新しい拡張子>
の形で出力する(例:image.jpg.webp
)
- 画像変換してファイル出力するとき、
- ソースファイル削除機能 (-D, –delete)
- 画像変換時に、変換元の画像を自動で削除する
- ファイル入力 (-i, –input)
- その他
- 同時処理数指定 (-T, –threads)
- 画像処理を同時に行えるスレッド数を変更する(デフォルトでは 4)
- Yes to all (-y, –yes) / No to all (-n, –no)
- ファイルを上書きする必要が生じたとき、それを毎回 Yes or No に設定する
- これを指定しなかった場合、ファイルごとに Yes or No を尋ねる
- プレビュー機能 (-v)
- 変換後の画像をコマンドライン上でプレビュー表示する
- バージョン表示 (-V, –version)
- ヘルプ機能 (-h, –help)
- コマンドのヘルプを表示する
- 同時処理数指定 (-T, –threads)
現状は基本的な画像処理機能+いくつかのファイル入出力オプションという感じです。
ユニークな機能としてはコマンドライン上でのプレビュー表示ですかね。こちらの画像のように、わざわざ GUI のファイルマネージャを開かなくとも、処理完了後の画像をコマンドラインで確認できます(無理矢理表示させているので解像度は低いですが…)。
前回の記事から変わったところ
マルチスレッド処理を実装した
例えば数百枚の画像を処理させようとしたとき、1枚1枚画像処理させるとあまりにも時間がかかってしまいます。そこで画像1枚毎にスレッドを作成し、指定した同時処理数の範囲内で画像処理を並列処理で実行させています。このあたりの処理には tokio を使いました。
for image_filepath in image_files_list {
let thread_task = if is_save_required(&args) {
...
// Make a thread task.
ThreadTask {
args: args.clone(),
input_path: image_filepath,
output_path: Some(output_path),
extension: Some(extension),
ask_result: ask_result,
}
}
else {
// If saving is not required, create a thread task without an output path.
ThreadTask {
args: args.clone(),
input_path: image_filepath,
output_path: None,
extension: None,
ask_result: AskResult::NoProblem,
}
};
// Add the thread task to the thread_tasks.
thread_tasks.push(thread_task);
}
各スレッドの処理を指示するための構造体として ThreadTask
というものを用意しています。ここでは画像の入力元・出力先、コマンドライン引数、出力先の拡張子などを指定しています。画像の出力が必要ない場合は出力先のファイルパスと拡張子を None
としています。
その後、指定した数のスレッドを作成し、順次タスクを実行していきます。
for _thread_num in 0..threads {
let thread_tasks = Arc::clone(&thread_tasks);
let count = Arc::clone(&count);
let tx = tx.clone();
let file_io_lock = Arc::clone(&file_io_lock);
let thread = tokio::spawn(async move {
loop {
let thread_task = {
let mut thread_tasks = thread_tasks.lock().unwrap();
thread_tasks.pop()
};
...
let thread_task = thread_task.unwrap();
let process_result = process(thread_task, file_io_lock.clone()).await;
match tx.send(ThreadResult {
process_result: Some(process_result),
finish: false,
}).await {
Ok(_) => {},
Err(e) => {
println!("Send error: {}", e.to_string());
}
}
// Count up the number of processed images.
let mut count = count.lock().unwrap();
*count += 1;
}
});
tasks.push(thread);
}
苦労した点は画像のファイル出力ですね。並列で画像を出力させた場合、環境によっては画像出力時に座標がずれる、色調が変わるなどの異常が見られました。
よく見たら画像出力がバグってるな
— yotio (@yotiosoft) May 13, 2024
I/O 系はシングルスレッドでやらないと駄目か? pic.twitter.com/vqzdLzxocO
よって画像処理のみを並列処理化し、画像出力だけは排他的に実行するようにしました。画像出力専用のスレッドを作成しても良かったんですが、現状はロック変数を用意しておき、ロックを取得した画像処理スレッドだけが画像を出力できるようにしています。
// Save the image
// Saving images at the same time can be a heavy load, so we need to lock the file I/O.
// *lock is used to lock the file I/O.
let save_status = {
let mut lock = file_io_lock.lock().unwrap();
*lock += 1;
let ret = image.save_image(output_path.to_str()).map_err(rierr)?;
ret
};
mozjpeg から jpeg-encoder に変更した
JPEG のエンコーダを mozjpeg から jpeg-encoder に変更しました。
mozjpeg、使い勝手良く長い事使わせていただいていたんですが、画像を一気に数十枚処理させるとブロッキングしてしまったり例外を起こしてしまう現象が見られました。
画像出力と画像クオリティ値(圧縮率)の設定くらいしか使っていなかったので、よりシンプルな jpeg-encoder を採用しました。
mozjpeg では一度バイナリデータに対して compress を実行して、画像出力時にそのバイナリデータを出力するという形になるのですが、jpeg-encoder では画像のファイル出力も含めて jpeg_encoder::Encoder
で行っており、その際に引数で画像クオリティ値を指定します。ですので、--compress
オプションを実行する際にはクオリティ値を保存しておくにとどめておき、画像出力時にクオリティ値を指定して出力するようにしました。
- mozjpeg の場合:
/// Compress the image. /// quality: Option<f32> 0.0 - 100.0 fn compress(&mut self, quality: Option<f32>) -> Result<(), RusimgError> { let quality = quality.unwrap_or(75.0); // default quality: 75.0 let image_bytes = self.image.clone().into_bytes(); let mut compress = Compress::new(ColorSpace::JCS_RGB); compress.set_scan_optimization_mode(ScanMode::AllComponentsTogether); compress.set_size(self.size.width, self.size.height); compress.set_quality(quality); let comp = compress.start_compress(image_bytes).map_err(|e| RusimgError::FailedToCompressImage(Some(e.to_string())))?; self.image_bytes = Some(comp.finish().map_err(|e| RusimgError::FailedToCompressImage(Some(e.to_string())))?); self.operations_count += 1; Ok(()) } /// Save the image to a file. fn save(&mut self, path: Option<PathBuf>) -> Result<(), RusimgError> { // image_bytes == None の場合、DynamicImage を 保存 if self.image_bytes.is_none() { ... } // image_bytes != None の場合、mozjpeg::Compress で圧縮したバイナリデータを保存 else { let mut file = std::fs::File::create(&save_path).map_err(|e| RusimgError::FailedToCreateFile(e.to_string()))?; file.write_all(&self.image_bytes.as_ref().unwrap()).map_err(|e| RusimgError::FailedToWriteFIle(e.to_string()))?; self.metadata_output = Some(file.metadata().map_err(|e| RusimgError::FailedToGetMetadata(e.to_string()))?); } ... }
- jpeg-encoder の場合:
/// Compress the image. /// quality: Option<f32> 0.0 - 100.0 /// Because the jpeg_encoder crate compresses the image when saving it, the compress() method does not need to do anything. /// So this method only sets the quality value. fn compress(&mut self, quality: Option<f32>) -> Result<(), RusimgError> { let quality = quality.unwrap_or(75.0); // default quality: 75.0 self.required_quality = Some(quality); self.operations_count += 1; Ok(()) } /// Save the image to a file. fn save(&mut self, path: Option<PathBuf>) -> Result<(), RusimgError> { ... // If compression is specified, save with compression (using jpeg_encoder crate) if let Some(quality) = self.required_quality { let encoder = Encoder::new_file(&save_path, quality as u8).map_err(|e| RusimgError::FailedToCreateFile(e.to_string()))?; encoder.encode(&self.image.to_rgb8(), self.size.width as u16, self.size.height as u16, ColorType::Rgb).map_err(|e| RusimgError::FailedToSaveImage(e.to_string()))?; self.metadata_output = Some(std::fs::metadata(&save_path).map_err(|e| RusimgError::FailedToGetMetadata(e.to_string()))?); } ... }
システム構成の変更:ライブラリクレートを分離
今回、rusimg のコアな部分はライブラリクレート librusimg として分離し、アプリケーションとしての rusimg とは完全に別のプロジェクトのクレートとして公開しております。
Rust では一つのプロジェクトに対して、アプリケーションであるバイナリクレートと、ライブラリであるライブラリクレートをそれぞれ公開することが可能です。dptran ではそうしています。では、なぜ rusimg ではわざわざライブラリクレートを別のクレートとして公開したかというと…
- dptran はアプリケーションとライブラリの双方に必要な依存クレートがほとんど
- 一方、rusimg はアプリケーションのためには必要だが、rusimg 自体をライブラリ化するときには不要になってしまう依存クレートが多い
- clap, regex, viuer, glob, colored, tokio, futures が該当
- 実は現状、Rust で「ライブラリクレートの場合はこの依存クレートは含めない」という設定は難しい
- features フラグを使えばできるが、導入するユーザ側にフラグを正しく設定してもらわなければならない
- ライブラリを導入するときに依存クレートを含めないためのフラグを設定してもらうか、バイナリクレートをインストールするときに依存クレートを含めるフラグを設定してもらうかの二択
- この辺の話は以前、記事に書いています
こんなわけで、ライブラリとして使ってもらい場合にあまりにも無駄になってしまう依存クレートが多く、ライブラリとしてもバイナリとしても気軽に使ってもらいたいという側面があったのが大きな理由です。
この手の話はだいぶ前から enable-features
として Rust プロジェクトの issue に立っているのですが、依然として放置されてしまっています…
細かい機能の追加
個人的にあると便利だなと思っていた二重拡張子機能とファイル名末尾追記機能を追加しました。このへんはファイル名を弄っているだけなので詳細は省きます。
今後追加したい機能
前回の記事とほぼ同じですが、
- gif、RAW 形式への対応
- gif はアニメーションが入っちゃうのでちょっと難しいかも
- svg への対応?
- BMP/JPEG/PNG/WebP への一方通行の変換くらいならできるかも
- 動画(mp4 など)からの画像キャプチャ機能
- Inpainting 機能
- 消しゴムマジック的なもの
- Style Transfer 機能
- 画像の質感を他の画像に適用
- 顔検出・モザイク機能
- 画像二値化
- エッジ検出機能
- GUI アプリ化
- おそらく librusimg を利用した別のアプリケーションとして公開
こんなところですかね。あまり多機能をすぎるのは、ライブラリ化することを考えたときに、無駄に機能が増えてしまうなと思ったりするところなんですが、そこは features フラグを活用していければなと思います。
どちらかといえば、問題は多機能すぎるとメンテの難易度が増えることくらいですかね…。特に依存するライブラリが増えれば増えるほど依存先の更新時の対応が面倒になります。mozjpeg なんかはクレートをバージョンアップしたら仕様がガラッと変わってたりして大変でした。
おわりに
個人的には並列処理プログラミングの練習とトレイト実装の練習の良いきっかけになったので満足です。
自分が使いやすいかどうかが基準な自己満足作品ではありますが、便利なツールに仕上がっていると思いますので、気が向いたらご利用ください。
今後も dptran と同じくちょくちょく更新していきます。まだまだ未完成なのでこれから rusimg をどうぞよろしくお願いいたします。