Edited at

GolangでアニメーションGifをリサイズする

More than 1 year has passed since last update.


はじめに

前回の記事の「Slackのemojiをドラッグ&ドロップで追加できるアプリをGolangで作った」で、ようやくアニメーションGif(以下アニメGif)のリサイズが出来たのでメモがてら記事を書きます。

上記のアプリを作った当初はgifのリサイズを行うと色がおかしくなってしまう問題があって、gifの対応を見送りました。

落ち着いて調べ直したら色々と分かったことがあったので、お役に立てば幸いです。


サンプル

ネットで探してきた素材の縮小例です。

http://droid-chan.net/archives/338



小さくなった分荒くはなりますが、リサイズ出来てちゃんと動いています。


概要

今回は以下のライブラリを使用してリサイズします。

nfnt/resize

処理の流れは以下の順序で行います。


  1. アニメGifを読み込む

  2. 現在のgifのサイズを取得

  3. リサイズする比を計算

  4. アニメGif内のgif画像を1枚ずつ縮小

  5. アニメGifをファイルに出力

こう書くと単純ですが、4の工程が中々難しかったです。


コード

リサイズに使用した関数を転記します。

一部説明の都合上、処理を入れ替えている箇所があります。

実際に使用したソースなので、jpgやpngのリサイズの処理も入っています。

func resizeImage(filePath string, maxSize float64) error {

const postFix string = "_resized"
base := filepath.Base(filePath)
ext := filepath.Ext(filePath)
ext = strings.ToLower(ext)

imageFile, err := os.Open(filePath)
if err != nil {
log.Fatal(err)
}
defer imageFile.Close()

var decImage image.Image
var gifImage *gif.GIF
var imageConfig image.Config
if ext == ".jpg" || ext == ".jpeg" {
decImage, err = jpeg.Decode(imageFile)
if err != nil {
log.Println(err)
}
_, err = imageFile.Seek(io.SeekStart, 0)
if err != nil {
return err
}
imageConfig, err = jpeg.DecodeConfig(imageFile)
if err != nil {
return err
}
} else if ext == ".png" {
decImage, err = png.Decode(imageFile)
if err != nil {
return err
}
_, err = imageFile.Seek(io.SeekStart, 0)
if err != nil {
return err
}
imageConfig, err = png.DecodeConfig(imageFile)
if err != nil {
return err
}
} else if ext == ".gif" {
gifImage, err = gif.DecodeAll(imageFile)
if err != nil {
return err
}
imageConfig = gifImage.Config
if err != nil {
return err
}
} else {
return nil
}

width := float64(imageConfig.Width)
height := float64(imageConfig.Height)

var ratio float64
if width > height && width > maxSize {
ratio = maxSize / width
} else if height > maxSize {
ratio = maxSize / height
} else {
ratio = 1
}

tmpFileName := base[0:len(base)-len(ext)] + postFix + ext
tmpFile, err := os.Create(tmpFileName)
if err != nil {
return err
}
defer tmpFile.Close()

if ratio == 1 {
_, err = imageFile.Seek(io.SeekStart, 0)
if err != nil {
return err
}
_, err := io.Copy(tmpFile, imageFile)
if err != nil {
log.Fatal(err)
return err
}
} else {
if ext == ".jpg" || ext == ".jpeg" {
resized := resize.Resize(uint(math.Floor(width*ratio)), uint(math.Floor(height*ratio)),
decImage, resize.Lanczos3)
jpeg.Encode(tmpFile, resized, nil)
} else if ext == ".png" {
resized := resize.Resize(uint(math.Floor(width*ratio)), uint(math.Floor(height*ratio)),
decImage, resize.Lanczos3)
png.Encode(tmpFile, resized)
} else if ext == ".gif" {
for index, frame := range gifImage.Image {
rect := frame.Bounds()
tmpImage := frame.SubImage(rect)
resizedImage := resize.Resize(uint(math.Floor(float64(rect.Dx())*ratio)),
uint(math.Floor(float64(rect.Dy())*ratio)),
tmpImage, resize.Lanczos3)
// Add colors from original gif image
var tmpPalette color.Palette
for x := 1; x <= rect.Dx(); x++ {
for y := 1; y <= rect.Dy(); y++ {
if !contains(tmpPalette, gifImage.Image[index].At(x, y)) {
tmpPalette = append(tmpPalette, gifImage.Image[index].At(x, y))
}
}
}
// After first image, image may contains only difference
// bounds may not start from at (0,0)
resizedBounds := resizedImage.Bounds()
if index >= 1 {
marginX := int(math.Floor(float64(rect.Min.X) * ratio))
marginY := int(math.Floor(float64(rect.Min.Y) * ratio))
resizedBounds = image.Rect(marginX, marginY, resizedBounds.Dx()+marginX,
resizedBounds.Dy()+marginY)
}
resizedPalette := image.NewPaletted(resizedBounds, tmpPalette)
draw.Draw(resizedPalette, resizedBounds, resizedImage, image.ZP, draw.Src)
gifImage.Image[index] = resizedPalette
}
// Set size to resized size
gifImage.Config.Width = int(math.Floor(width * ratio))
gifImage.Config.Height = int(math.Floor(height * ratio))
gif.EncodeAll(tmpFile, gifImage)
}
}
return nil
}

// Check if color is already in the Palette
func contains(colorPalette color.Palette, c color.Color) bool {
for _, tmpColor := range colorPalette {
if tmpColor == c {
return true
}
}
return false
}


ポイント

アニメGifをリサイズするにあたってポイントを幾つか解説します。


アニメGifを読み込む時

gif.DecodeAllを使用します。

var gifImage *gif.GIF

gifImage, err = gif.DecodeAll(imageFile)

他のjpgやpngと違い難しいのは、DecodeAllから返却される値が*GIFというところです。

Decodeから返却されるのはImageなのでそのままresize.Resizeに渡せるのですが、GIFにはImageがありません。

参考:

type GIF struct {

Image []*image.Paletted // The successive images.
Delay []int // The successive delay times, one per frame, in 100ths of a second.
LoopCount int // The loop count.
Disposal []byte
BackgroundIndex byte
}

そのため、ここからImageを作る必要があります。


各画像を縮小する

先程取得したGIFはアニメGifの画像全てが格納されているため、rangeを使用してループで回します。

for index, frame := range gifImage.Image {

GIF内のImagerangeで回すことで、indexPalettedを取得できます。

PalettedSubImageを使って、Imageに変換できます。

Paletted内のRectangleを使用して、原寸大のImageを作ります。

rect := frame.Bounds()

tmpImage := frame.SubImage(rect)

あとはこのImageResizeに渡して、リサイズすればいいだけ・・・と思っていたらそう簡単ではありませんでした。


ImageをPalettedに変換する

GIFImageではなく、Palettedで画像を保持しています。

そのため、リサイズしたImagePalettedに戻さなければいけません。

NewPalettedという関数を使用すれば出来そうです。

func NewPaletted(r Rectangle, p color.Palette) *Paletted

そのためにはRectanglePaletteを作る必要があります。

まずはPaletteを作ります。


Paletteを作成

Gifは256色しか使用できませんが、リサイズ直後の画像は256色を超えている可能性があります。

(このことに中々気付けませんでした・・・。)

当初別のソースを参考にしてpalette.Plan9Paletteとして使用しましたが、色がおかしくなってしまいました。

元画像が持っていた色と違うから当たり前ですね・・・。

試行錯誤した結果、元画像から色情報を取得するという結論に至りました。

// Add colors from original gif image

var tmpPalette color.Palette
for x := 1; x <= rect.Dx(); x++ {
for y := 1; y <= rect.Dy(); y++ {
if !contains(tmpPalette, gifImage.Image[index].At(x, y)) {
tmpPalette = append(tmpPalette, gifImage.Image[index].At(x, y))
}
}
}

読み込んだ元画像をループで回し、まだ追加していないColorがあったらPaletteに追加するという処理です。

At関数でPalettedの特定のポイントのColor情報を取得できます。

これで元画像の色情報を持ったPaletteが出来ました。


Rectangleを作成

ここも一筋縄ではいきませんでした・・・。

アニメGifの1枚目以降はフレームの再利用が可能なため、差分しか情報がない場合があります。

つまり、座標が(0,0)から始まらない場合があります。

しかし、Resizeはこのことを考慮してくれず、描画開始位置が(0,0)に戻ってしまうため、描画開始位置を適切な箇所にずらしてあげなければいけません。

// After first image, image may contains only difference

// bounds may not start from at (0,0)
resizedBounds := resizedImage.Bounds()
if index >= 1 {
marginX := int(math.Floor(float64(rect.Min.X) * ratio))
marginY := int(math.Floor(float64(rect.Min.Y) * ratio))
resizedBounds = image.Rect(marginX, marginY, resizedBounds.Dx()+marginX,
resizedBounds.Dy()+marginY)
}

元画像のMin.XMin.Yの位置を取得して縮小し、リサイズ後のRectangleに反映します。

これでRectanglePaletteが出来ました。


リサイズした画像を描画

NewPalettedを使用してPalettedを作成後、リサイズした画像をDrawを使用して描画します。

描画したPalettedを元画像と差し替えます。

resizedPalette := image.NewPaletted(resizedBounds, tmpPalette)

draw.Draw(resizedPalette, resizedBounds, resizedImage, image.ZP, draw.Src)
gifImage.Image[index] = resizedPalette


アニメGifを出力

アニメGifを出力する前に、Configのサイズをリサイズ後のサイズに変更します。

その後、gif.EncodeAllでファイルを出力します。

// Set size to resized size

gifImage.Config.Width = int(math.Floor(width * ratio))
gifImage.Config.Height = int(math.Floor(height * ratio))
gif.EncodeAll(tmpFile, gifImage)

これでリサイズされたアニメGifが出力されます。


作ってみて

最初はどうしたものかと思いましたが、どんなデータが入っているのかとにかく出力してみたらなんとなく解決の糸口が見つかりました。

もし簡単に出来る方法がありましたら、ぜひ教えて下さい。

今回の経験を応用して、アニメGifで色々やってみたいと思います。