Grayscale Comics

I picked up drawing as a hobby during the COVID-19 pandemic, and in order to learn from the all time greats I started doing master studies. To be able to concentrate purely on form & lighting I started to convert images into grayscale. I’ve primarily been doing studies from comic books, and decided to automate the process of turning images into grayscale. The goal of this post is to show how I automated this process via GO.

In short summary our application does the following:

  • Ensure we’re dealing with a valid CBZ archive and we can write to the destination folder
  • extract each image and convert to grayscale
  • write new images to destination folder

For the sake of this demonstration I’ve decided not to write any unit tests and simply keep everything in main with the exception of a few ‘helper’ functions.

package main

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"
)

func pathExists(path string) bool {
	if _, err := os.Stat(path); os.IsNotExist(err) {
		return false
	}
	return true
}

func main() {
	fmt.Println("Usage: ./grayscale-cbz --dest=/path/to/dest <FILE>")
	dest := flag.String("dest", "/tmp/", "Destination directory for grayscale images")

	flag.Parse()

	// check if flags valid
	cbz := flag.Args()[0]
	if !pathExists(cbz) {
		fmt.Printf("path to file '%s' doesn't exist\n", cbz)
		os.Exit(1)
	}

	// check if cbz is valid
	ext := filepath.Ext(cbz)
	if ext != ".cbz" {
		fmt.Printf("invalid file extension: '%s'\n", ext)
		os.Exit(1)
	}

	// check if dest exists (if no create or exit with error)
	if !pathExists(*dest) {
		fmt.Printf("destination folder '%s' doesn't exist. Creating...", *dest)
		err := os.MkdirAll(*dest, os.ModePerm)
		if err != nil {
			fmt.Printf("error creating directory: %s", err.Error())
			os.Exit(1)
		}
		fmt.Println("done!")
	}
}

We begin by introducing a flag called “dest” for the destination folder we want to store our images in. I introduce the function ‘pathExists’ (lines 10-15) that helps me to identify if the input file & my destination folder both exist. If the former doesn’t exist or doesn’t have the extension ‘.cbr’ the program exits. For clarity I could have included ‘.zip’ here as a valid archive, but any other extension would require its own implementation. If the target directory doesn’t exist we attempt to create it or exit if we don’t have permission to do so.

The next step is to open our previously validated archive file and start iterating over its members:

func main() {
  ....

  r, err := zip.OpenReader(cbz)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	defer r.Close()

	for _, f := range r.File {
        	...
 	}
}

What’s following is the meat & potatoes of our program:

func main() {

  ...

  for _, f := range r.File {
		if f.FileInfo().IsDir() {
			os.MkdirAll(*dest+"/"+f.Name, os.ModePerm)
			continue
		}

		fmt.Printf("converting image: %s\n", f.Name)
		zipped, err := f.Open()
		if err != nil {
			fmt.Printf("error reading '%s': %s\n", f.Name, err.Error())
			continue
		}

		// grayscale images
		img, _, err := image.Decode(zipped)
		if err != nil {
			fmt.Printf("error decoding image '%s': %s\n", f.Name, err.Error())
			continue
		}

		gray := image.NewGray(img.Bounds())
		for x := 0; x < img.Bounds().Max.X; x++ {
			for y := 0; y < img.Bounds().Max.Y; y++ {
				color := img.At(x, y)
				gray.Set(x, y, color)
			}
		}

    ...

}

First take a look at lines 6-9. This section is important as most archives contain a folder in which the images are stored in. We want to make sure we create this in our destination as otherwise we would have to modify each file’s path in our archive.

What follows is basically we deconstruct our image into an ‘image.Image’ struct, take the colour of each pixel within the image’s bounds, and convert it to grayscale with the ‘image.Gray.Set’ method while respecting its luminosity.

Lastly we move on to writing our converted images to the destination folder:

func parseExt(path string) string {
	split := strings.Split(path, ".")
	return "." + split[len(split)-1]
}

func today() string {
	now := time.Now()
	return now.Format("20060102")
}

func main() {

    	...

 	newExt := parseExt(f.Name)
	newFile := *dest + "/" + f.Name[0:len(f.Name)-len(newExt)] + "_GRAYSCALED_" + today() + newExt
	out, err := os.Create(newFile)
	if err != nil {
		fmt.Printf("error creating destination image path '%s': %s\n", newFile, err.Error())
		continue
	}
	defer out.Close()

	err = jpeg.Encode(out, gray, nil)
	if err != nil {
		fmt.Printf("error saving image at destination '%s': %s\n", newFile, err.Error())
		continue
	}
}

We introduce two other helper functions to help us establish the extension of our files, and to be able to annotate our new file names with the current date. We write each file with our grayscale images.

And that’s about it! To see the full code go to my Github page.