これはWEBCAMP ENGINEER COMMUNITY Advent Calendar 2022の2日目の記事です!
背景
この間、PCが苦手な友人が困っていました。
友人「画像をECサイトに登録するために、JPEGに変換して、正方形に切り抜かないといけない。めんどくさい」
私「どうやってやってるの?」
友人「スマホでやってる」
私「PC使えば?無料ツールとか探せばあるでしょ」
友人「PCよく分からん。ペイント使えば出来るらしいけど。一発でやる方法ないかな〜」
アドベントカレンダー用の題材に困っていた私は、「これだ!」と思い、勝手に自作することにしました。
仕事で使ったことはありませんが、最近Goが気になっているので、勉強も兼ねてGoで作ってみました。
Go製ツールは配布や実行もしやすいので、友人に使ってもらう際はその点も良さそうです。
作ったもの
こういった画像を、
by Renée French.
こうする。
切り抜く位置は変更可能です。
正方形ではなく長方形も可能です。
画像フォーマットも.png
から.jpg
に変更される。
元画像のファイル形式はpng、jpg、jpeg、gif、webpに対応しています。
変換後のファイル形式はpng、jpg、jpeg、gifに対応しています。
webp形式は、標準パッケージでは読み込めないので対応しないつもりでしたが、友人からの要望で対応することにしました。
こちら(https://pkg.go.dev/golang.org/x/image/webp) のpackageをお借りしました。
対話式CLIツールにしました。
トリミング後の画像サイズや、切り抜き位置、フォーマットを指定できます。
引数で受け取れるようにした方が使いやすいかもですが、気分で。
image-convert $ go run main.go
トリミング後画像の幅をpxで入力してください(省略すると元画像と同じ大きさになります) >>75
トリミング後画像の高さをpxで入力してください(省略すると元画像と同じ大きさになります) >>75
画像ファイルのパスを拡張子付きで入力してください >>/[適当なパス]/gopher.png
デフォルトのトリミング範囲は画像の中央です
トリミング範囲を右に50px変更したい場は「50」、左に50px変更したい場は「-50」と入力してください(省略可) >>-30
トリミング範囲を上に50px変更したい場は「50」、下に50px変更したい場は「-50」と入力してください(省略可) >>70
出力ファイルのパスを拡張子付きで入力してください >>/[適当なパス]/gopher2.jpg
出力完了!
コード
goのバージョンは1.19です。
今回はGOPATHの配下にsrc/image-convert/というディレクトリを作って作業します。
webpに対応するために外部のパッケージを利用するので、そのための準備として以下のコマンドを実行します。
go mod init
go.modが作成されます。
go get -u golang.org/x/image/...
外部パッケージのダウンロードとインストールをまとめて行うコマンドです。
Go1.17からgo get
の代わりにgo install
推奨らしいが、モジュール内で管理したいパッケージについてはこれまで通りgo get
も使えるとかなんとか。とりあえずドキュメントに書いてあったコマンドを使いました。
go.sumが作成され、"golang.org/x/image/webp"
のimportとwebp.Decode()
の使用ができるようになりました。
ファイル構成
image-convert $ tree .
.
├── go.mod
├── go.sum
└── main.go
コード
以下コードです。
勉強も兼ねて作ったので、コメントが多くて汚いですがご容赦ください。
正直Goの基礎が疎かなので、良くない書き方があったり、もっと良い書き方が多数あると思われます。
その際はコメントで指摘してもらえると嬉しいです。
package main
import (
"bufio"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strconv"
"strings"
"golang.org/x/image/webp" // webpに対応させるための外部パッケージです。
)
// 後述するSubImageメソッドによるトリミングのために用意
type SubImager interface {
SubImage(r image.Rectangle) image.Image
}
func main() {
scanner := bufio.NewScanner(os.Stdin)
// 出力画像の幅と高さをpxで指定
fmt.Print("トリミング後画像の幅をpxで入力してください(省略すると元画像と同じ大きさになります) >>")
scanner.Scan()
OUTPUT_WIDTH, _ := strconv.Atoi(scanner.Text())
fmt.Print("トリミング後画像の高さをpxで入力してください(省略すると元画像と同じ大きさになります) >>")
scanner.Scan()
OUTPUT_HEIGHT, _ := strconv.Atoi(scanner.Text())
// 入力画像パス
fmt.Print("画像ファイルのパスを拡張子付きで入力してください >>")
scanner.Scan()
iPath := scanner.Text()
inputPath := strings.Replace(iPath, `"`, "", -1) // windowsでファイルのパスをコピーするとデフォルトでダブルクォーテーションがつくので除去するように
// 画像ファイルを開く。戻り値は*os.File型とerror型
inputFile, err := os.Open(inputPath)
assert(err, "パスが不正です '"+inputPath+"'")
defer inputFile.Close() // リソース破棄のためにdeferを使ってファイルをクローズする。deferを使うことで関数終了時に実行される
// image.Decodeでファイルオブジェクトを画像オブジェクトに変換。戻り値はImage型とstring型とerror型
// Image型は、形式によって*image.YCbCr型だったり*image.NRGBA型だったりする
// JPEGの場合はRGBでなくYCbCrで保存し、PNGの場合はNRGBAかRGBAで保存するらしい
// ※よくわかってない
// string型は、"png"や"jpg"だったりするが、今回は不要なのでアンダースコア変数で無視する
var inputImg image.Image // if文の中で変数の代入ができないので先に宣言する
if strings.ToLower(filepath.Ext(inputPath)) == ".webp" {
inputImg, err = webp.Decode(inputFile)
} else {
inputImg, _, err = image.Decode(inputFile)
}
assert(err, "画像データとして読み込めません")
// Bounds()の戻り値はRectangle型。Dx()はRectangle型のクラスメソッドで幅を取得する
inputWidth := inputImg.Bounds().Dx()
inputHeight := inputImg.Bounds().Dy()
fmt.Println("デフォルトのトリミング範囲は画像の中央です")
fmt.Print("トリミング範囲を右に50px変更したい場は「50」、左に50px変更したい場は「-50」と入力してください(省略可) >>")
scanner.Scan()
xSlide, _ := strconv.Atoi(scanner.Text())
fmt.Print("トリミング範囲を上に50px変更したい場は「50」、下に50px変更したい場は「-50」と入力してください(省略可) >>")
scanner.Scan()
ySlide, _ := strconv.Atoi(scanner.Text())
// 切り取りに使う座標を計算する処理(汚い)
// if OUTPUT_(WIDTH/HEIGHT) == 0 の条件分岐は、トリミング後画像の幅が未指定の場合は元画像と同じ大きさで出力するための記述
x1, x2, y1, y2 := 0, 0, 0, 0
if xSlide != 0 {
if OUTPUT_WIDTH == 0 {
x1 = 0 + xSlide
} else {
x1 = ((inputWidth - OUTPUT_WIDTH) / 2) + xSlide
}
} else {
if OUTPUT_WIDTH == 0 {
x1 = 0
} else {
x1 = (inputWidth - OUTPUT_WIDTH) / 2
}
}
if OUTPUT_WIDTH == 0 {
x2 = inputWidth
} else {
x2 = x1 + OUTPUT_WIDTH
}
if ySlide != 0 {
if OUTPUT_HEIGHT == 0 {
y1 = 0 + ySlide
} else {
y1 = ((inputHeight - OUTPUT_HEIGHT) / 2) + ySlide
}
} else {
if OUTPUT_HEIGHT == 0 {
y1 = 0
} else {
y1 = (inputHeight - OUTPUT_HEIGHT) / 2
}
}
if OUTPUT_HEIGHT == 0 {
y2 = inputHeight
} else {
y2 = y1 + OUTPUT_HEIGHT
}
// Imageインターフェース(image.Image)にはSubImageメソッドがないので、
// SubImagerインターフェースを作って型アサーションすることでSubImageメソッドを使えるようにしている
// ※よくわかってない
trimmedImg := inputImg.(SubImager).SubImage(image.Rect(x1, y1, x2, y2))
// 出力画像パス
fmt.Print("出力ファイルのパスを拡張子付きで入力してください >>")
scanner.Scan()
oPath := scanner.Text()
outputPath := strings.Replace(oPath, `"`, "", -1) // windowsでファイルのパスをコピーするとデフォルトでダブルクォーテーションがつくので除去するように
// 出力ファイルを生成
outputFile, err := os.Create(outputPath)
assert(err, "ファイルの作成に失敗しました '"+outputPath+"'")
defer outputFile.Close()
// 出力ファイルパスの拡張子によってエンコードする形式を変える
switch strings.ToLower(filepath.Ext(outputPath)) {
case ".jpeg", ".jpg":
jpeg.Encode(outputFile, trimmedImg, nil)
fmt.Println("出力完了!")
case ".png":
png.Encode(outputFile, trimmedImg)
fmt.Println("出力完了!")
case ".gif":
gif.Encode(outputFile, trimmedImg, nil)
fmt.Println("出力完了!")
default:
fmt.Println("拡張子が対応していません (対応拡張子: jpeg/jpg/png/gif)")
err := os.Remove(outputPath)
assert(err, "出力ファイルは不完全です")
}
}
// errorがあれば例外を出す
func assert(err error, msg string) {
if err != nil {
panic(err.Error() + ":" + msg)
}
}
Windows用の実行ファイルを作る
友人のPCはおそらくWindowsなので、Windowsですぐ使えるように実行ファイルを作って配布します。
以下のコマンドを実行します。go mod init
は実行済みの前提です。
GOOS=windows GOARCH=amd64 go build
Windows用にコンパイルして、image-convert.exe
という実行ファイルを作成します。
この実行ファイルimage-convert.exe
を自宅のWindowsPCに送って、実行してみます。
ダブルクリックすると、コマンドプロンプトが立ち上がります。管理者権限で実行する必要があるかもです。
無事動作しました!簡単で良いですね!Goの利点のひとつです。
ISSUE
現状思いつくISSUEです。気分が乗るか、友人からの要望があれば実装するかも。
- コマンドライン引数に非対応
- 円形など、長方形以外のトリミングに非対応
- エラーハンドリングが(おそらく)不十分
- 複数画像の一括変換に非対応
- 画像サイズを小さくする機能がない
- テスト書いてない
余談
作った後に、ネット上に似たようなのないかな、と思い検索したら案の定高機能で堅牢そうなものがありました。
https://github.com/tenntenn/gohandson/tree/master/imgconv/ja
また、Goで本格的にCLIツールを作るなら、cobraというライブラリを使うのが良さそうです。
参考にさせていただいたサイト
https://zenn.dev/kou_pg_0131/articles/go-get-image-size
https://shiro-16.hatenablog.com/entry/2020/05/29/130508
https://rennnosukesann.hatenablog.com/entry/2019/08/14/175308