logo
Published on

Easier GIF creation in Golang

Authors
  • avatar
    Name
    Gary
    Twitter

The Golang Standard Library

You and your friend were talking last night and came up with a great idea for a meme turned into a GIF. Let's say you want to use the Lord Of the Rings scene where Boromir says "One does not simply walk into Mordor".

So, first we get a screenshot of one of the frames from the video just to start with.

One does not simply template

Then the question is, how should we make it? Well, you know some golang, so maybe there are some tools that exist for go. Doing a quick search shows that the standard library has support built in! Great! image/gif. The library doesn't seem to have any examples though... Well, with some quick searching you are able to cobble something together. You are required to make a palette, which is a little mysterious, but it is pretty easy to put together a basic list of colors. Then using the StackOverflow examples we create a GIF with a single frame. The delay is a little odd since it is an integer for hundredths of seconds, but whatever.

Hand Made Palette and Choose Nearest

package main

import (
	"bytes"
	"image"
	"image/color"
	"image/color/palette"
	"image/draw"
	"image/gif"
	"image/jpeg"
	"os"
)

// https://www.w3schools.com/colors/colors_names.asp
var (
	// Black to white
	Black     color.RGBA = color.RGBA{0x00, 0x00, 0x00, 0xFF} // #000000
	DarkGray  color.RGBA = color.RGBA{0x26, 0x26, 0x26, 0xFF} // #262626
	Gray      color.RGBA = color.RGBA{0x80, 0x80, 0x80, 0xFF} // #808080
	LightGray color.RGBA = color.RGBA{0xD3, 0xD3, 0xD3, 0xFF} // #D3D3D3
	White     color.RGBA = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF} // #FFFFFF

	// Primary Colors
	Red  color.RGBA = color.RGBA{0xFF, 0x00, 0x00, 0xFF} // #FF0000
	Lime color.RGBA = color.RGBA{0x00, 0xFF, 0x00, 0xFF} // #00FF00
	Blue color.RGBA = color.RGBA{0x00, 0x00, 0xFF, 0xFF} // #0000FF

	// half strength primary colors
	Maroon   color.RGBA = color.RGBA{0x80, 0x00, 0x00, 0xFF} // #800000
	Green    color.RGBA = color.RGBA{0x00, 0x80, 0x00, 0xFF} // #008000
	NavyBlue color.RGBA = color.RGBA{0x00, 0x00, 0x80, 0xFF} // #000080

	// full strength primary mixes
	Yellow  color.RGBA = color.RGBA{0xFF, 0xFF, 0x00, 0xFF} // #FFFF00
	Aqua    color.RGBA = color.RGBA{0x00, 0xFF, 0xFF, 0xFF} // #00FFFF
	Magenta color.RGBA = color.RGBA{0xFF, 0x00, 0xFF, 0xFF} // #FF00FF

	// half strength primary mixes
	Olive  color.RGBA = color.RGBA{0x80, 0x80, 0x00, 0xFF} // #808000
	Purple color.RGBA = color.RGBA{0x80, 0x00, 0x80, 0xFF} // #800080
	Teal   color.RGBA = color.RGBA{0x00, 0x80, 0x80, 0xFF} // #008080

)

var simplePalette color.Palette = color.Palette{Black, DarkGray, Gray, LightGray, White, Red, Lime, Blue, Maroon, Green, NavyBlue, Yellow, Aqua, Magenta, Olive, Purple, Teal}

func try1() {
	fileData, _ := os.ReadFile("OneDoesNotSimply_Template.jpg")
	img, _ := jpeg.Decode(bytes.NewReader(fileData))

	bound := img.Bounds()
	palettedImg := image.NewPaletted(bound, simplePalette)
	draw.Draw(palettedImg, bound, img, image.Point{}, draw.Src)

	anim := gif.GIF{}
	anim.Image = append(anim.Image, palettedImg)
	anim.Delay = append(anim.Delay, 100)

	file, _ := os.Create("OneDoesNotSimply_try1.gif")
	defer file.Close()
	_ = gif.EncodeAll(file, &anim)
}

func main() {
	try1()
}

And... The results are terrible!

golang gif choose nearest

Did we do something wrong? Or is the golang package just not very good? With some more searching you find that most people don't actually create their own palette. But instead use either Plan9 or WebSafe. Let's give Plan9 a try.

Plan9 Palette and Choose Nearest

func try2() {
	fileData, _ := os.ReadFile("OneDoesNotSimply_Template.jpg")
	img, _ := jpeg.Decode(bytes.NewReader(fileData))

	bound := img.Bounds()
	palettedImg := image.NewPaletted(bound, palette.Plan9)
	draw.Draw(palettedImg, bound, img, image.Point{}, draw.Src)

	anim := gif.GIF{}
	anim.Image = append(anim.Image, palettedImg)
	anim.Delay = append(anim.Delay, 100)

	file, _ := os.Create("OneDoesNotSimply_try2.gif")
	defer file.Close()
	_ = gif.EncodeAll(file, &anim)
}

func main() {
	try2()
}

Slightly better, but still terrible!

golang gif choose nearest with Plan9 colors

You did notice that most of the examples online were using something called dithering. So let's try that with our first hand crafted palette. Instead of using draw.Draw we have to use draw.FloydSteinberg.

Hand Made Palette and Dithering

func try3() {
	fileData, _ := os.ReadFile("OneDoesNotSimply_Template.jpg")
	img, _ := jpeg.Decode(bytes.NewReader(fileData))

	bound := img.Bounds()
	palettedImg := image.NewPaletted(bound, simplePalette)
	drawer := draw.FloydSteinberg
	drawer.Draw(palettedImg, bound, img, image.Point{})

	anim := gif.GIF{}
	anim.Image = append(anim.Image, palettedImg)
	anim.Delay = append(anim.Delay, 100)

	file, _ := os.Create("OneDoesNotSimply_try3.gif")
	defer file.Close()
	_ = gif.EncodeAll(file, &anim)
}

func main() {
	try3()
}
golang gif with dithering

This is a lot better than it was, but the picture still isn't great. The colors are washed out and there are these strange red and green dots all over the image.

Let's try with the Plan9 palette again.

Plan9 Palette and Dithering

func try4() {
	fileData, _ := os.ReadFile("OneDoesNotSimply_Template.jpg")
	img, _ := jpeg.Decode(bytes.NewReader(fileData))

	bound := img.Bounds()
	palettedImg := image.NewPaletted(bound, palette.Plan9)
	drawer := draw.FloydSteinberg
	drawer.Draw(palettedImg, bound, img, image.Point{})

	anim := gif.GIF{}
	anim.Image = append(anim.Image, palettedImg)
	anim.Delay = append(anim.Delay, 100)

	file, _ := os.Create("OneDoesNotSimply_try4.gif")
	defer file.Close()
	_ = gif.EncodeAll(file, &anim)
}

func main() {
	try4()
}
golang gif with dithering Well, that looks pretty good! I guess we should throw away our custom made palette. There is a strange graininess to the image though.

Now we are actually ready to make the GIF. Using some other code we took a series of screenshots and have them available as a slice of images.

We need to add the text now.

Moving GIF With Plan9 Palette and Dithering

func makeGif(frames []image.Image) {
	s1 := "ONE DOES NOT SIMPLY"
	s2 := "MAKE A GIF"
	AddMemeText(frames, s1, s2, easygif.Crimson)

	hundredthOfSecondDelay := 10

	// Process the images.
	imagesPal := make([]*image.Paletted, 0, len(frames))
	delays := make([]int, 0, len(frames))

	// Fill the request channel with images to convert
	for frameIndex := range frames {
		screenShot := frames[frameIndex]
		bounds := screenShot.Bounds()
		ssPaletted := image.NewPaletted(bounds, palette.Plan9)
		imagesPal = append(imagesPal, ssPaletted)
		delays = append(delays, hundredthOfSecondDelay)
		draw.FloydSteinberg.Draw(ssPaletted, bounds, screenShot, image.Point{})
	}

	// Create the GIF struct and write it to a file.
	g := &gif.GIF{
		Image: imagesPal,
		Delay: delays,
	}
	file, _ := os.Create("OneDoesNotSimply_try5.gif")
	defer file.Close()
	_ = gif.EncodeAll(file, g)
}

func AddMemeText(frames []image.Image, s1, s2 string, c color.Color) {
	fontSize := 60.0
	font, err := truetype.Parse(goregular.TTF)
	if err != nil {
		panic("")
	}
	face := truetype.NewFace(font, &truetype.Options{
		Size: fontSize,
	})

	for i := range frames {
		frame := frames[i]
		dc := gg.NewContextForImage(frame)
		bound := frame.Bounds()
		dc.SetFontFace(face)
		dc.SetColor(c)
		dc.DrawStringAnchored(s1, float64(bound.Dx())/2, float64(bound.Dy())*.10, 0.5, 0.5)
		dc.DrawStringAnchored(s2, float64(bound.Dx())/2, float64(bound.Dy())*.90, 0.5, 0.5)

		frames[i] = dc.Image()
	}
}
golang gif with dithering Plan9 One does not simply make a gif

The GIF looks... ok... The image is super grainy. and the graininess seems to dance around as the image moves.

As far as I can tell, yes, this is as good as it gets using the standard library.

The Problems

The main two problems are that mysterious palette and dithering.

The Palette

For a GIF, the palette contains every color that can be used in the GIF. And there can only be up to 256 colors in the palette. That is quite the limitation. The Plan9 palette chose colors that are evenly distributed around the color space, Which means that approximately 0% of them will be the actual colors in the source image. That is why the choose nearest color examples looked terrible.

Dithering

Dithering solves the problem of not having enough colors in your palette by swapping between the two nearest colors. The mix is determined by the relative distance to the two colors. If the pixel is somewhere between red and maroon, but closer to red, then when dithering we would mainly use red, with maroon mixed in randomly at a lower rate.

That swapping between the two nearest colors also creates the main drawback of dithering that we saw in the examples above. Dithering creates the graininess and dancing pixels. To make the problem as clear as possible I created a really simple gif with a circle than changes size. Dithering is used.

Drawing Tan circle that changes sizes with dithering enabled

The darker blue dots dance around and sometimes make lines and other shapes.

The problem isn't nearly as obvious on the moving GIF of Boromir above, but you can still see it on his forehead pretty easily.

The Standard Library Is Not Easy

All you wanted to do was to quickly create the funny GIF you and your friend thought up last night, but instead you have wasted 2 hours learning about the GIF package and some of the troublesome nuances of the GIF image format. You probably didn't want to know that a GIF can only have 256 colors in it, or that the delay between frames has to be set as an int that represents hundredth of seconds...

And at the end, it does not look very good. It is grainy and Boromir's forehead looks funny if you look too close.

Making a GIF was supposed to be quick, easy, and look like this! golang gif with choose best colors - One does not simply make a gif

Introducing EasyGif

To make the GIF creation process easier in go, I created the package easygif. With it you can create a GIF faster and easier.

github.com/GaryBrownEEngr/easygif

To get started, first install the package with the command:

go get github.com/GaryBrownEEngr/easygif@latest

The package has built in functionality to:

  • Make a GIF using the nearest color found in the Plan9 palette
  • Make a GIF using dithering and the Plan9 palette
  • Make an even better GIF by computing the most common colors in the image and then not use dithering.
  • Take a screenshot or trimmed screenshot
  • Take a screenshot video with or without trimming
  • Save and load a slice of images to a Blob file so you can get the GIF generation right without having to re-capture the frames each time

All the work we went through above can be boiled down to two steps.

Collect the frames

This code saves the frames to a binary file with the encoding/gob package.

package main

import (
	"fmt"
	"time"

	"github.com/GaryBrownEEngr/easygif"
)

func collectFrames() {
	time.Sleep(time.Second * 3)
	fmt.Println("GO!")
	frames, _ := easygif.ScreenshotVideoTrimmed(30, time.Millisecond*50, 150, 1050, 380, 1270)
	_ = easygif.NearestWrite(frames, time.Millisecond*100, "./examples/globsave/globsave1.gif")
	fmt.Println("Collection Done.")

	err := easygif.SaveFramesToFile(frames, "save.bin")
	if err != nil {
		panic(err)
	}
}

func main() {
	collectFrames()
}

Make the GIF

Here I make the same GIF with 3 different configurations. The best choice is the most common color algorithm though.

package main

import (
	"fmt"
	"image"
	"image/color"
	"time"

	"github.com/GaryBrownEEngr/easygif"
	"github.com/fogleman/gg"
	"github.com/golang/freetype/truetype"
	"golang.org/x/image/font/gofont/goregular"
)

func createGifs() {
	frames, err = easygif.LoadFramesToFile("save.bin")
	// frames, err := easygif.LoadFramesToFile("./save.bin")
	if err != nil {
		panic(err)
	}

	fmt.Println("Adding Text.")
	s1 := "ONE DOES NOT SIMPLY"
	s2 := "MAKE A GIF"
	AddMemeText(frames, s1, s2, easygif.Crimson)

	fmt.Println("Encoding GIF.")
	_ = easygif.NearestWrite(frames, time.Millisecond*100, "OneDoesNotSimplyMakeAGIF_Nearest.gif")
	_ = easygif.DitheredWrite(frames, time.Millisecond*100, "OneDoesNotSimplyMakeAGIF_Dithered.gif")
	_ = easygif.MostCommonColorsWrite(frames, time.Millisecond*100, "OneDoesNotSimplyMakeAGIF_MostCommon.gif")
}

func AddMemeText(frames []image.Image, s1, s2 string, c color.Color) {
	fontSize := 60.0
	font, err := truetype.Parse(goregular.TTF)
	if err != nil {
		panic("")
	}
	face := truetype.NewFace(font, &truetype.Options{
		Size: fontSize,
	})

	for i := range frames {
		frame := frames[i]
		dc := gg.NewContextForImage(frame)
		bound := frame.Bounds()
		dc.SetFontFace(face)
		dc.SetColor(c)
		dc.DrawStringAnchored(s1, float64(bound.Dx())/2, float64(bound.Dy())*.10, 0.5, 0.5)
		dc.DrawStringAnchored(s2, float64(bound.Dx())/2, float64(bound.Dy())*.90, 0.5, 0.5)

		frames[i] = dc.Image()
	}
}

func main() {
	createGifs()
}

easygif.Nearest

golang gif with choose nearest colors - One does not simply make a gif

easygif.Dithered

golang gif with dithering - One does not simply make a gif

easygif.MostCommonColors

golang gif with choose best colors - One does not simply make a gif

Conclusion

As you can see, the amount of code required to create a great looking GIF has been reduced from what we saw above with the standard library to just 1 line:

easygif.MostCommonColorsWrite(frames, time.Millisecond*100, "SuperEasy.gif")

What are you waiting for? Go GIF with EasyGif!