🔮

[Go] JPEG/PNG 画像の幅・高さを変えずに容量を圧縮する

2023/04/09に公開

はじめに

業務で携わっているプロジェクトで、ユーザがアップロードした画像の容量をサーバサイドで小さくしたい要件がありました。あまり経験のないことで、調べるなかでいろいろ学びがあったので記事に残します。

画像の圧縮処理をするコードサンプル

圧縮処理のサンプルコードは以下のとおりです。今回圧縮対象としたのは jpeg と png のみです。実装には Go 標準の image パッケージとそのサブパッケージである image/jpegimage/png を使用しました。Go のバージョンは v1.20.3 です。

nfnt/resize を紹介している記事がいくつか見つかりましたが、2023/04/09 現在メンテナンスが行われておらず、リポジトリもアーカイブされているようなので採用を見送りました。

package main

import (
	"fmt"
	"image"
	"image/jpeg"
	"image/png"
	"log"
	"net/http"
	"os"
)

func main() {
	if len(os.Args) < 3 || os.Args[2] == "" {
		log.Println("引数にファイルパスを指定してください")

		return
	}

	inputFileName := os.Args[1]
	outputFileName := os.Args[2]

	b, err := os.ReadFile(inputFileName)
	if err != nil {
		panic(err)
	}

	// MIME Sniffing: https://triple-underscore.github.io/mimesniff-ja.html
	mimeType := http.DetectContentType(b)
	if mimeType != "image/jpeg" && mimeType != "image/png" {
		log.Println("処理対象のファイル種別ではありません")

		return
	}

	r, err := os.Open(inputFileName)
	if err != nil {
		panic(err)
	}
	defer r.Close()

	srcImg, format, err := image.Decode(r)
	if err != nil {
		panic(err)
	}

	if err := r.Close(); err != nil {
		log.Println(err)
	}

	dstImgWriter, err := os.Create(outputFileName)
	if err != nil {
		panic(err)
	}
	defer dstImgWriter.Close()

	switch format {
	case "jpeg":
		if err := jpeg.Encode(dstImgWriter, srcImg, &jpeg.Options{
			Quality: 85,
		}); err != nil {
			panic(err)
		}
	case "png":
		encoder := png.Encoder{
			CompressionLevel: png.DefaultCompression,
		}

		if err := encoder.Encode(dstImgWriter, srcImg); err != nil {
			panic(err)
		}
	default:
		panic(fmt.Errorf("不明なエンコード方式: %s", format))
	}

	log.Println(format, "画像の圧縮が完了しました")
}

実際プロジェクトでは、画像圧縮処理を GCP の Cloud Functions 上で動かしています。そのため、ファイルの読み書き等実務のコードと若干異なる部分があります。

jpeg、png の各エンコード方式ごとに圧縮方法の指定のしかたが異なっているため、それぞれ解説します。

jpeg の圧縮

srcImg, encode, err := image.Decode(r)
if err != nil {
	panic(err)
}

dstImgWriter, err := os.Create(outputFileName)
if err != nil {
	panic(err)
}

if err := jpeg.Encode(dstImgWriter, srcImg, &jpeg.Options{
	Quality: 85,
}); err != nil {
	panic(err)
}

jpeg 画像を圧縮するには、画像を image.Image interface に変換し、image/jpeg パッケージの Encode のオプションに Quality というパラメータを指定します。このパラメータは「圧縮率」「品質」など、さまざまな呼び方がされているようですが、本記事では Quality という表記で統一します。Quality の値は 1 ~ 100 の範囲で指定が可能です。値が高ければ高いほど元の画質が維持されますが、エンコード後のファイルサイズは大きくなります。逆に低すぎると画像の劣化が生じます。余談ですが、こういった現象を「圧縮アーティファクト」と呼ぶらしいです (ChatGPT に教えてもらった)。

https://ja.wikipedia.org/wiki/圧縮アーティファクト

jpeg の圧縮は非可逆圧縮方式であるため、元の画像に戻すことはできません。したがって、Quality の値を決定する際には、元の画像の品質を維持しつつ、ファイルサイズを小さくする必要があります。Google Developers によると Quality の値を 85 に設定することが適切であるとされています。

https://developers.google.com/speed/docs/insights/OptimizeImages?hl=ja

また、こちらの記事での検証結果によると「通常 85 でよい、それ以上だと容量が急増する。画質を優先したい場合は 90」とされていました。容量がどのくらい小さくなるかは元の画像によるようです。(85 を指定したからといって画像の容量が 85% になるとは限らない)

https://qiita.com/miyanaga/items/a616261de490cc342d08

ちなみに image/jpeg パッケージではデフォルト値が 75 になっていました。なぜ 75 なのかは軽く調べてみましたが、手がかりがつかめずわかりませんでした。

https://github.com/golang/go/blob/231f290e51e130a1699d5c29d28133d68f43d2e9/src/image/jpeg/writer.go#L564-L565

最終的には無難な 85 を指定することに落ち着きました。

png の圧縮

srcImg, encode, err := image.Decode(r)
if err != nil {
	panic(err)
}

dstImgWriter, err := os.Create(outputFileName)
if err != nil {
	panic(err)
}

encoder := png.Encoder{
	CompressionLevel: png.DefaultCompression,
}

if err := encoder.Encode(dstImgWriter, srcImg); err != nil {
	panic(err)
}

png の場合は jpeg と同様にまずは画像を読みこんで image.Image interface に変換します。png.Encoder 構造体の CompressionLevel フィールドに圧縮レベルを指定します。ここで指定が可能な値は DefaultCompression NoCompression BestSpeed BestCompression の 4 つの値です。image/png のエンコード処理内部では compress/zlib パッケージが使われています。これは zlib の機能を Go でも使えるように実装したパッケージです。

https://pkg.go.dev/compress/zlib

本家の zlib では圧縮レベルが 0 ~ 9 の間で指定可能で、Z_NO_COMPRESSION に 0、Z_NO_COMPRESSION に 1、Z_DEFAULT_COMPRESSION に 6、Z_BEST_COMPRESSION に 9 を割り当てられていました。数字が大きいほど圧縮率が高いが、圧縮に時間がかかる仕様のようです (どれくらい差が出るのかちゃんと検証してないです)。

https://github.com/madler/zlib/blob/04f42ceca40f73e2978b50e93806c2a18c1281fc/zlib.h#L190-L194

https://github.com/madler/zlib/blob/04f42ceca40f73e2978b50e93806c2a18c1281fc/deflate.c#L616-L622

(C が読めないので抜粋箇所が正しいかどうかが不安...)

zlib は可逆圧縮なので圧縮しても画質の変化はありません。画像のサイズを小さくすることにこだわるのであれば BestCompression を指定するのが良いのでしょうが、処理速度が膨らんでしまうおそれがあります。

Z_DEFAULT_COMPRESSION は、速度と圧縮の間のデフォルトの妥協点 (現在、レベル 6 に対応します) を表します。

こちらは python 標準の zlib ライブラリからの引用ですが、デフォルトの圧縮レベルが速度と圧縮のバランスが良いそうです。ということで、png での圧縮レベルは DefaultCompression としています。

ちなみに圧縮レベルを DefaultCompression にしてエンコードを行う場合は、png.Encoder 構造体を作らずとも以下のように image/png.Encode を使うことで簡単に同じことができます。わざわざ明示的に CompressionLevel の指定をする例を挙げていたのは圧縮レベルの説明をしやすくする意図がありました。

srcImg, encode, err := image.Decode(r)
if err != nil {
	panic(err)
}

dstImgWriter, err := os.Create(outputFileName)
if err != nil {
	panic(err)
}

if err := png.Encode(dstImgWriter, srcImg); err != nil {
	panic(err)
}

まとめ

圧縮処理は標準パッケージのみで簡単に実装することができました。コード自体はとてもシンプルでしたが、指定するオプションの意味を調べていくと、今まであまり意識したことのない領域に首を突っ込むことができ勉強になりました。本記事では趣旨と逸れるため取り上げませんでしたが、そもそも zlib って何者なのかとか、圧縮関連の各種技術について調べていくと面白かったので、参考リンクを載せておきます。

参考

https://tech-blog.sitateru.com/2019/02/google-cloud-functions.html

https://www.fujitsu.com/jp/about/research/techguide/list/image-compression/

https://www.plan-b.co.jp/blog/tech/10282/

https://www.s-yata.jp/docs/zlib/

https://github.com/madler/zlib

GitHubで編集を提案
株式会社BuySell Technologies

Discussion