JPEGの品質と容量の最適バランスを自動判定:自分でつくるRuby製画像圧縮ツール
はじめに
メディロムグループの穗滿です。普段は主に情シス部門や開発部門のマネジメントをしているのですが、時々コードを書きたくなる衝動にかられます。今回もその1つです。
目的
簡単な画像処理なら外部のクラウドサービスを使わず自前で用意したもので処理させ、品質とコストカットを両立したい。
背景
画像はWEBで頻繁に使われるメディアですから、見た目の品質を下げたくないのが本音です。ただ、そうなると基本的には大容量となり、クラウドサービスでの通信料にインパクトが出やすいです。後述しますが、最近では画像処理をクラウドでやってくれるとか、AIでいい感じに画像処理をしてくれるサービスも続々と出てきています。でも、結局のところ通信量にお金を払うのか、最適化処理にお金を払うのかの違いであって、コストがかかりやすいのは一緒です。そこで、自前で画像最適化ツールを作れないものか?と思ったのです。
画像の拡張子の歴史
画像の拡張子の歴史は以下の記事にわかりやすくまとめられていますが、BMPから始まり、GIF → JPEG → PNG → SVG → WebP、AVIFなど、最近ではWEBに最適な低容量で高品質にみえるフォーマットも登場してきました。
それでも根強い人気のJPEG
しかしながらJPEGは汎用性が高く、ほとんどの環境で表示可能なので人気です。そのため、今回はJPEGに絞った話題にしたいと思います。
昔だとJPEGminiを使ったりしてました。非常に高い圧縮率ながら見た目を損なわない技術に度肝を抜かれました。最近ではimgixなどの様に、CDNを兼ねながら画像のリサイズ・加工・最適化をリアルタイムでURLパラメータでやってくれるサービスまで出てきて、非常に便利な世の中になったなぁと思います。
処理コスト
エンジニアとしては、外部に信頼できる機能が使えることは開発の責任範疇を委ねることもできるのでありがたいのですが、たいていのサービスは海外製のドル払いであり、円安や物価上昇の影響で費用も年々上がって行っている感覚があります。メディロムグループの開発においては基本的にクラウドファーストなのですが、コストを考えなければならない立場としてはなかなか難しい時代だと感じます。
そこで、車輪の再発明にはなってしまうのですが、改めて自前で画像の品質と容量の最適バランスを自動で見つけてくれる様なツールをRubyの勉強もかねて作ってみました。
今回の前提
- まずはお手軽に画像処理をして遊ぶつもりで、手元のMac上で動くツールとする(コマンドライン)
- 対応フォーマットはJPEGのみとする
- 変換前の画像名と変更後の画像名を指定できるものとする
- サイズ指定がなければ同じ画像サイズ(横縦のピクセル)で画像の最適化を行う
- サイズ指定があればその画像サイズにリサイズした上で画像の最適化を行う
- デフォルトのクオリティ値を持つが、指定も可能にする
- オリジナル画像と生成画像の差分判定を行う
- Rubyで作る(弊社の技術スタックのため)
セットアップ手順
1.必要なbrewパッケージをインストールする
# libvipsとImageMagick, JPEG最適化ツールをインストール
brew install vips
brew install imagemagick
brew install jpegoptim
brew install jpegtran
2.プロジェクトディレクトリを作成し、移動する
mkdir image_compressor
cd image_compressor
3.Gemfileを作成する
touch Gemfile
Gemfileの中身は以下の様な感じです。
# Gemfile
source 'https://rubygems.org'
gem 'image_processing', '~> 1.12' # 画像処理操作をシンプルなインターフェイスで提供
gem 'ruby-vips' # libvipsのRubyバインディング
gem 'mini_magick' # ImageMagickのRubyラッパー
gem 'image_optim' # 画像最適化ツール
gem 'image_optim_pack' # image_optimの最適化ツールをRubygemsとしてパッケージング
4.Gemをインストールする
bundle install
5.Rubyコード例
以下のRubyコードを「compressor.rb」という名前で保存します。
#!/usr/bin/env ruby
require 'optparse'
require 'fileutils'
require 'vips'
require 'image_optim'
require 'shellwords'
class DynamicImageCompressor
# 画質の段階をより細かく設定(最低65, 最高95)
QUALITY_STEPS = [95, 90, 85, 80, 75, 70, 65]
# 許容される最大の差異
MAX_DIFF_THRESHOLD = 0.1
def initialize
@image_optim = ImageOptim.new(
pngout: false,
svgo: false,
jpegoptim: {strip: :all}, # 画像が持ってる位置情報やコメントを削除
jpegtran: {progressive: true} # プログレッシブJPEG形式で保存指定
)
end
def process(input_path, output_path, options = {})
max_width = options[:max_width]
max_height = options[:max_height]
min_quality = options[:min_quality] || 65
begin
# 元画像の情報を取得
original_image = Vips::Image.new_from_file(input_path)
original_width = original_image.width
original_height = original_image.height
# サイズ指定がない場合は元のサイズを使用
max_width ||= original_width
max_height ||= original_height
# リサイズが必要かどうかを確認
resize_needed = original_width > max_width || original_height > max_height
ext = File.extname(input_path).downcase
# 入力ファイルの保存先にテンポラリディレクトリを作成
temp_dir = File.join(File.dirname(output_path), 'temp_compression')
FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir)
case ext
when '.jpg', '.jpeg'
result_path = compress_jpeg(input_path, temp_dir, max_width, max_height, resize_needed, min_quality)
else
raise "サポートされていないフォーマット: #{ext}"
end
# 出力ファイルをコピー
FileUtils.cp(result_path, output_path)
# テンポラリファイルの削除
FileUtils.rm_rf(temp_dir)
# 元のファイルサイズと新しいファイルサイズを比較
original_size = File.size(input_path)
new_size = File.size(output_path)
reduction = ((original_size - new_size).to_f / original_size * 100).round(2)
puts "✅ #{output_path} 生成完了"
puts " 元サイズ: #{format_size(original_size)}"
puts " 新サイズ: #{format_size(new_size)}"
puts " 削減率: #{reduction}%"
# 自動選択の品質を表示
best_quality = result_path.split('_q').last.split('.').first.to_i
puts " 選択された品質: #{best_quality}"
rescue => e
puts "エラーが発生しました: #{e.message}"
puts e.backtrace
end
end
private
def format_size(size_in_bytes)
units = ['B', 'KB', 'MB', 'GB']
unit_index = 0
size = size_in_bytes.to_f
while size >= 1024 && unit_index < units.length - 1
size /= 1024
unit_index += 1
end
"#{size.round(2)} #{units[unit_index]}"
end
def compress_jpeg(input_path, temp_dir, max_width, max_height, resize_needed, min_quality)
best_quality = nil
best_path = nil
best_size = Float::INFINITY
best_diff = Float::INFINITY
# オリジナル画像を読み込み
original_image = Vips::Image.new_from_file(input_path)
# リサイズが必要な場合はリサイズ
if resize_needed
# アスペクト比を維持しながらリサイズ
scale = [max_width.to_f / original_image.width, max_height.to_f / original_image.height].min
new_width = (original_image.width * scale).to_i
new_height = (original_image.height * scale).to_i
puts "リサイズ: #{original_image.width}x#{original_image.height} → #{new_width}x#{new_height}"
resized_image = original_image.resize(scale)
else
puts "リサイズなし: 元のサイズを維持 (#{original_image.width}x#{original_image.height})"
resized_image = original_image
end
# まずリサイズ画像をテンポラリファイルに保存(リファレンス画像)
resized_temp_path = File.join(temp_dir, "resized_temp.jpg")
resized_image.write_to_file(resized_temp_path, Q: 100, strip: true)
# リファレンス画像を読み込み
reference_image = Vips::Image.new_from_file(resized_temp_path)
puts "画質最適化中..."
QUALITY_STEPS.each do |quality|
next if quality < min_quality # 指定された最小品質より低い場合はスキップ
output_path = File.join(temp_dir, "temp_q#{quality}.jpg")
# VIPSを使用して画像を保存
resized_image.write_to_file(output_path, Q: quality, strip: true)
# ImageOptimを使って追加の最適化
@image_optim.optimize_image!(output_path)
# ファイルサイズを取得
file_size = File.size(output_path)
# 比較用に画像を読み込み
compressed_image = Vips::Image.new_from_file(output_path)
# 簡易的な画像差分計算
begin
# 両方の画像から平均値を計算
ref_avg = reference_image.avg
comp_avg = compressed_image.avg
# 平均値の差分(絶対値)
avg_diff = (ref_avg - comp_avg).abs
puts " quality=#{quality} → 差分=#{avg_diff.round(2)}, サイズ: #{format_size(file_size)}"
# 差分が閾値以下の場合
if avg_diff <= MAX_DIFF_THRESHOLD
if best_path.nil? || file_size < best_size
best_quality = quality
best_path = output_path
best_size = file_size
best_diff = avg_diff
end
elsif best_path.nil? || avg_diff < best_diff
# 差分が閾値以下のものがなければ、最も差分が小さいものを選択
best_quality = quality
best_path = output_path
best_size = file_size
best_diff = avg_diff
end
rescue => e
puts " 画像比較中にエラー: #{e.message} - 画質 #{quality} をスキップ"
end
end
puts "最適な画質として #{best_quality} を選択 (差分: #{best_diff.round(2)})"
# テンポラリリサイズファイルを削除
FileUtils.rm(resized_temp_path) if File.exist?(resized_temp_path)
best_path
end
end
# --- コマンドラインインターフェース部分 ---
options = {}
opt = OptionParser.new
opt.banner = "Usage: ruby compressor.rb [options] input_file output_file"
opt.on("-wN", "--width=N", Integer, "最大幅 (デフォルト: 元の画像のサイズ)") { |v| options[:max_width] = v }
opt.on("-hN", "--height=N", Integer, "最大高さ (デフォルト: 元の画像のサイズ)") { |v| options[:max_height] = v }
opt.on("-qN", "--quality=N", Integer, "最小品質 (0-100、デフォルト: 65)") { |v| options[:min_quality] = v }
opt.on("-tN", "--threshold=N", Float, "差分閾値 (デフォルト: 0.1)") { |v| DynamicImageCompressor::MAX_DIFF_THRESHOLD = v }
opt.parse!
if ARGV.size != 2
puts opt
exit 1
end
input, output = ARGV
compressor = DynamicImageCompressor.new
compressor.process(input, output, options)
6.Rubyコードに実行権限を付与する
chmod +x compressor.rb
7.実行例
# 全てデフォルトの場合
bundle exec ruby compressor.rb 入力画像.jpg 出力画像.jpg
# 横幅のみ指定の場合
# 幅が1000ピクセルに制限され、高さは比率に応じて自動調整される
bundle exec ruby compressor.rb -w 1000 入力画像.jpg 出力画像.jpg
# 閾値のみ指定の場合
bundle exec ruby compressor.rb -t 0.05 入力画像.jpg 出力画像.jpg
# 最小品質のみ指定(これ以上下がらない様にする)
bundle exec ruby compressor.rb -q 70 入力画像.jpg 出力画像.jpg
ちなみに、なんの引数も入れずに実行しようとすると「コマンドラインインターフェース部分」に設定したヘルプが表示されるプチ機能付きです。ちょっとだけ親切。
% bundle exec ruby compressor.rb
Usage: ruby compressor.rb [options] input_file output_file
-w, --width=N 最大幅 (デフォルト: 元の画像のサイズ)
-h, --height=N 最大高さ (デフォルト: 元の画像のサイズ)
-q, --quality=N 最小品質 (0-100、デフォルト: 65)
-t, --threshold=N 差分閾値 (デフォルト: 0.1)
オプション説明
- -w, --width=N (最大横幅):デフォルトは元画像の横幅。元画像より大きくはできない。
- -h, --height=N (最大高さ):デフォルトは元画像の高さ。元画像より大きくはできない。
- -t, --threshold=N (差分閾値):画質重視では小さい値に、圧縮率重視なら大きな値に。
- -q, --quality=N (最小品質):デフォルト65が最低
オプションはすべて任意であり、必要に応じて使い分けることができます。特に指定のない場合は、元のサイズを維持しながら自動的に最適な品質を選択して圧縮が行われます。
8.結果
Zennに画像をそのままアップロードすると自動圧縮されてしまうため、結果表示の共有となります。
test1.jpg
我が社から撮影したレインボーブリッジです!眺め最高ー!
処理前のtest1.jpgの情報
処理後のtest1_out.jpgの情報(パラメータ指定なし)
コンソール上での表示
% bundle exec ruby compressor.rb test1.jpg test1_out.jpg
リサイズなし: 元のサイズを維持 (2500x1875)
画質最適化中...
quality=95 → 差分=0.0, サイズ: 1.06 MB
quality=90 → 差分=0.01, サイズ: 826.94 KB
quality=85 → 差分=0.02, サイズ: 602.82 KB
quality=80 → 差分=0.03, サイズ: 489.31 KB
quality=75 → 差分=0.07, サイズ: 410.16 KB
quality=70 → 差分=0.07, サイズ: 377.35 KB
quality=65 → 差分=0.17, サイズ: 342.91 KB
最適な画質として 70 を選択 (差分: 0.07)
✅ test1_out.jpg 生成完了
元サイズ: 982.02 KB
新サイズ: 377.35 KB
削減率: 61.57%
選択された品質: 70
test1.jpgの考察
Macの画像情報画面で見た画像サイズと若干ズレがあるのですが、確実に圧縮されながら見た目の品質は保てている様に思います。元画像にあったExif情報(画像を撮影したのいろんな情報)も当然削除されています。
test2.jpg
飛行機の上から撮影した青い空!白い雲!
処理前のtest2.jpgの情報
処理後のtest2_out1.jpgの情報(q=80を指定)
コンソール上での表示
% bundle exec ruby compressor.rb -q 80 test2.jpg test2_out.jpg
リサイズなし: 元のサイズを維持 (1980x1485)
画質最適化中...
quality=95 → 差分=0.0, サイズ: 512.76 KB
quality=90 → 差分=0.01, サイズ: 296.67 KB
quality=85 → 差分=0.02, サイズ: 175.57 KB
quality=80 → 差分=0.04, サイズ: 140.47 KB
最適な画質として 80 を選択 (差分: 0.04)
✅ test2_out.jpg 生成完了
元サイズ: 1.16 MB
新サイズ: 140.47 KB
削減率: 88.2%
選択された品質: 80
処理後のtest2_out2.jpgの情報(パラメータ指定なし)
コンソール上での表示
% bundle exec ruby compressor.rb test2.jpg test2_out2.jpg
リサイズなし: 元のサイズを維持 (1980x1485)
画質最適化中...
quality=95 → 差分=0.0, サイズ: 512.76 KB
quality=90 → 差分=0.01, サイズ: 296.67 KB
quality=85 → 差分=0.02, サイズ: 175.57 KB
quality=80 → 差分=0.04, サイズ: 140.47 KB
quality=75 → 差分=0.04, サイズ: 115.72 KB
quality=70 → 差分=0.1, サイズ: 102.33 KB
quality=65 → 差分=0.16, サイズ: 89.29 KB
最適な画質として 70 を選択 (差分: 0.1)
✅ test2_out2.jpg 生成完了
元サイズ: 1.16 MB
新サイズ: 102.33 KB
削減率: 91.41%
選択された品質: 70
test2.jpgの考察
あえて1回目はq=80をしてしてみましたが、見た目ではq=80も2回目で選ばれたq=70も大して差が無い様に思いました。もう少しエッジがはっきりした画像なら違う結果もあったかもしれません。
また、test1.jpg
と test2.jpg
をみると、どちらも品質が q=65
になったあたりから差分が大きくなって劣化し始める様な感じは見受けられました。
9.コードの補足
コメントはコード内にも簡易的に残していますが、一部補足させていただきます。
画質評価手法
ここでは詳細を割愛しますが、画像品質を評価する代表的指標にPSNRとSSIMというものがあります。当然、その辺のライブラリも入れて試みたのですが、正直あまりパッとした結果が得られなかったため、シンプルに画像の平均値(avg)の差分を使用することにしました。VIPSの最も基本的な機能のみを使用することで、多くのバージョンで動作することを期待しています。
PSNRやSSIMに関しては素晴らしい記事があるので、そちらをご参照ください。
差分値の話
いろんな画像で試していたところ、たとえば品質値q=65のときに0.16
、q=60のときに0.09
になるなど、品質が低いはずの時に差分が小さくなることがありました。通常、JPEGの品質が下がると差分値(劣化)は単調に増えるはずなので異常値です。
考えられる原因として、画像の内容(特定の色調・テクスチャ・エッジなど)がJPEG圧縮アルゴリズムと品質設定において、特異的に相互作用している可能性があります。また、単純に今回の画質評価手法でつかった「平均値の差分」という指標が、特定の圧縮結果を正確に捉えられていない可能性は十分にあります。
とはいえ、目視レベルで全く使えない画像になってるわけでもないし、圧縮も確実にされているので、参考値程度に考えるでいいのかもしれないと思っています。
10.おわりに
今回は時間都合でコマンドラインツールとして動くところまでになってしまいましたが、必要に応じて自社で管理するサーバーで動く様にすればWEBサービスとしても展開できると思います。次の記事ではWEBサービス化させてみるところを検討してみたいと思います。
その場合、リアルタイム性を求めるとかなり負荷が高いと思うので、どちらかというとアップロードした画像を最適処理化してサムネイル生成する・リサイズするなどの用途に向いているかもしれません。
また、PNGなども比較的実現しやすいと思うので、追加要件として検討しても良いなと思っています。
採用情報
メディロムグループでは以下のようなサービスを展開しています。
- 全国300店舗以上のリラクゼーションスタジオ「Re.Ra.Ku」
- 世界初!充電不要の活動量計「MOTHER bracelet」
- ヘルスケアコーチングアプリ「Lav」
健康って何をするにも大事ですよね。
健康でなければ楽しみも半減し、仕事も思う様にはいかないです。
メディロムグループでは健康・ヘルスケア領域に興味があるエンジニア、PMを絶賛募集中です!
効果がダイレクトにわかる自社サービスをグロースさせながら、ご自身の成長も目指されてみませんか?
Discussion