画像ファイルのサイズを縮小したい
2023年 GW をいかがお過ごしでしょうか。私はうっかり Bluesky に手を付けてしまい,以下の公式 Go パッケージで遊んでみたのですが
画像ファイルのアップロードでハマってしまいました。どうやら現状の公式 PDS では 1MB 以上の画像ファイルはサーバ側にアップロードできないみたいで(しかもアップロード失敗を返しこない)どうにか画像データを 1MB 以下に抑えようと試行錯誤してしまいました。今回はその辺の話を記しておきましょう。
さて,ここからは口調を改めて本題。
画像データのサイズを縮小する手段としては以下が挙げられるだろう(不可逆圧縮になるのは諦める)。
- JPEG 形式に変換する(特に PNG 形式に対しては効果大)
- 画像の品質を落とす(JPEG の場合)
- 画像のサイズを小さくする
というわけで,この方針に従って実際に画像データの縮小関数を書いてみよう。
完成形
とりあえず完成形はこんな感じになった。
const (
imageMaxSize = 1000
imageFileMaxSize = 1024 * 1024
)
func AjustImage(src []byte) (io.Reader, error) {
// check file size
if len(src) < imageFileMaxSize {
return bytes.NewReader(src), nil
}
// decode image
imgSrc, t, err := image.Decode(bytes.NewReader(src))
if err != nil {
return nil, errs.Wrap(err)
}
// convert JPEG
quality := 90
if t != "jpeg" {
b, err := convertJPEG(imgSrc, quality)
if err != nil {
return nil, errs.Wrap(err)
}
if len(b) < imageFileMaxSize {
return bytes.NewReader(b), nil
}
}
// quality down
for _, q := range []int{85, 55, 25} {
b, err := convertJPEG(imgSrc, q)
if err != nil {
return nil, errs.Wrap(err)
}
quality = q
if len(b) < imageFileMaxSize {
return bytes.NewReader(b), nil
}
}
// rectange of image
rctSrc := imgSrc.Bounds()
rate := 1.0
if rctSrc.Dx() > rctSrc.Dy() {
if rctSrc.Dx() > imageMaxSize {
rate = imageMaxSize / float64(rctSrc.Dx())
}
} else {
if rctSrc.Dy() > imageMaxSize {
rate = imageMaxSize / float64(rctSrc.Dy())
}
}
if rate >= 1.0 {
return nil, errs.Wrap(ecode.ErrTooLargeImage)
}
// scale down
dstX := int(float64(rctSrc.Dx()) * rate)
dstY := int(float64(rctSrc.Dy()) * rate)
imgDst := image.NewRGBA(image.Rect(0, 0, dstX, dstY))
draw.BiLinear.Scale(imgDst, imgDst.Bounds(), imgSrc, rctSrc, draw.Over, nil)
b, err := convertJPEG(imgDst, quality)
if err != nil {
return nil, errs.Wrap(err)
}
if len(b) > imageFileMaxSize {
return nil, errs.Wrap(ecode.ErrTooLargeImage)
}
return bytes.NewReader(b), nil
}
func convertJPEG(src image.Image, quality int) ([]byte, error) {
dst := &bytes.Buffer{}
if err := jpeg.Encode(dst, src, &jpeg.Options{Quality: quality}); err != nil {
return nil, errs.Wrap(err)
}
return dst.Bytes(), nil
}
前提として画像データは []byte 型のスライスに格納済みとする。これを AjustImage() 関数に渡すわけだ。
image.Image 型にデコードする
詳しく見ていこう。まずはサイズを確認。 1MB 以下のデータサイズならそのまま返す。
if len(src) < imageFileMaxSize {
return bytes.NewReader(src), nil
}
次に画像データを image.Image 型にデコードする。
imgSrc, t, err := image.Decode(bytes.NewReader(src))
if err != nil {
return nil, errs.Wrap(err)
}
JPEG 形式に変換してみる
作成した imgSrc
を JPEG 形式のバイナリに変換するのは簡単。以下の関数でできる。
func convertJPEG(src image.Image, quality int) ([]byte, error) {
dst := &bytes.Buffer{}
if err := jpeg.Encode(dst, src, &jpeg.Options{Quality: quality}); err != nil {
return nil, errs.Wrap(err)
}
return dst.Bytes(), nil
}
これを使って JPEG 以外の画像データを変換してサイズをチェックする。
quality := 90
if t != "jpeg" {
b, err := convertJPEG(imgSrc, quality)
if err != nil {
return nil, errs.Wrap(err)
}
if len(b) < imageFileMaxSize {
return bytes.NewReader(b), nil
}
}
この変換により 1MB 以下のサイズになれば,そのデータを返却している。品質は90で設定している(JPEG の品質は90以上では殆ど変わらないそうな)。
品質を落としてみる
続けて, JPEG データも含め,品質を 85, 55, 25 と落としていってデータサイズをチェックしていく。
for _, q := range []int{85, 55, 25} {
b, err := convertJPEG(imgSrc, q)
if err != nil {
return nil, errs.Wrap(err)
}
quality = q
if len(b) < imageFileMaxSize {
return bytes.NewReader(b), nil
}
}
品質を 85 まで落とすとファイルサイズが劇的に減るらしいので,ここで 1MB 以下になることを期待している。あとはオマケみたいなもの(笑)
画像のサイズを縮小してみる
品質 25 まで落としても 1MB を超える場合は,最後の手段として画像のサイズを縮小する。
まず縦横の大きい方の値を1,000ピクセルに縮小するよう比率を計算する。
rctSrc := imgSrc.Bounds()
rate := 1.0
if rctSrc.Dx() > rctSrc.Dy() {
if rctSrc.Dx() > imageMaxSize {
rate = imageMaxSize / float64(rctSrc.Dx())
}
} else {
if rctSrc.Dy() > imageMaxSize {
rate = imageMaxSize / float64(rctSrc.Dy())
}
}
if rate >= 1.0 {
return nil, errs.Wrap(ecode.ErrTooLargeImage)
}
もとより1,000ピクセルより小さいサイズであれば諦めてエラーを返している(そこから更に縮小するのはねぇ...)
次に計算した比率でスケールダウンする。
dstX := int(float64(rctSrc.Dx()) * rate)
dstY := int(float64(rctSrc.Dy()) * rate)
imgDst := image.NewRGBA(image.Rect(0, 0, dstX, dstY))
draw.BiLinear.Scale(imgDst, imgDst.Bounds(), imgSrc, rctSrc, draw.Over, nil)
b, err := convertJPEG(imgDst, quality)
if err != nil {
return nil, errs.Wrap(err)
}
if len(b) > imageFileMaxSize {
return nil, errs.Wrap(ecode.ErrTooLargeImage)
}
return bytes.NewReader(b), nil
画像サイズを縮小しても 1MB を超える場合は諦めてエラーを返している。
実行結果
今回書いた関数を使って大きいサイズの画像ファイルも Bluesky へどうにかアップロードできるようになった。
メッセージ中の URL からリンクカードを表示する際のアテンション画像もこの1MB制限に引っかかることが多いみたいなので,同様に対処して,上手く表示できるようになった。
こんな泥臭い方法じゃなくてもっとスマートにできるよー,というアイデアがありましたら,ぜひ教えて下さい 🙇
なお,今回作ったツールは完全に自分用(主にバッチ処理で使う予定)に作ったものなのであしからず。 ATP (Authenticated Transfer Protocol) の機能をほぼフル実装している CLI ツールとしては mattn さんのがおすすめ。
ここに書かれているコードはかなり参考にさせてもらっている。ありがたや。
参考
Discussion