The Mandelbrot set, step by step (3): image creation

Posted on
Artistic interpretation. An elder woman takes a photo with a point-and-shoot camera, while at her back there is a Mandelbrot set.
2018 rmhdev. Image source

Welcome to the third post of the series where I develop an app that displays the Mandelbrot set using the Go programming language.

Objectives

In this post we will improve the app to generate black & white images of the Mandelbrot set.

Requirements

Check the section about requirements in the previous post. If you want to see the code at this point of the history, browse the original repository or execute the next command to retrieve the code:

git checkout 85e9d1b

Representation of a Mandelbrot set

The low resolution version generated by our app is nice for the CLI, but not enough to grasp the infinite complexity of the Mandelbrot set. To achieve this, we’ll need the resolution and versatility of digital images.

Both CLI and image representations have something in common: for every pixel, we know if they are part of the Mandelbrot set. Let’s use this to create a new struct Representation that handles the state of every pixel using a two dimensional array:

// representation.go
package main

type Representation struct {
  points [][]bool
}

func CreateRepresentation(width int, height int) Representation {
  points := make([][]bool, height)
  for i := range points {
    points[i] = make([]bool, width)
  }
  return Representation{points}
}

func (r Representation) width() int {
  return len(r.points[0])
}

func (r Representation) height() int {
  return len(r.points)
}

func (r Representation) set(x int, y int, isInside bool) {
  r.points[y][x] = isInside
}

func (r Representation) isInside(x int, y int) bool {
  return r.points[y][x]
}

This type will help us setting and getting the state of every pixel. Let’s make use of it by generating a Representation from the struct Config; it’s the same double loop used in the main method, but instead of printing the output, we save the info in the new struct:

// config.go
// ...
func (c Config) representation(verifier Verifier) Representation {
  representation := CreateRepresentation(c.width, c.height)
  realC, imagC := 0.0, 0.0
  for y := 0; y < c.height; y++ {
    imagC, _ = c.toImag(y)
    for x := 0; x < c.width; x++ {
      realC, _ = c.toReal(x)
      representation.set(x, y, verifier.isInside(realC, imagC))
    }
  }
  return representation
}

Export the representation

Now that we have separated the representation of the Mandelbrot set from its creation, we can continue improving our app by creating a type responsible of exporting this representation to the format that we desire. Even though our final goal is generating images, let’s start with the CLI version:

// export.go
package main

import "fmt"

type Exporter struct {
  representation Representation
}

func (e Exporter) export() string {
  result := ""
  for y := 0; y < e.representation.height(); y++ {
    line := ""
    for x := 0; x < e.representation.width(); x++ {
      if e.representation.isInside(x, y) {
        line += "*"
      } else {
        line += "·"
      }
    }
    result += fmt.Sprintln(line)
  }
  return result
}

The export() method returns a string with the representation of the set. Using it in the main method is now straightforward:

package main
//...
func main() {
  //...
  config := Config{*width, *height, *rMin, *rMax, *iMin, *iMax}
  representation := config.representation(Verifier{*iterations})
  exporter := Exporter{representation}
  
  fmt.Print(exporter.export())
}

As you can see the separation of concerns has improved the readability of our code, but unfortunately this doesn’t bring us closer to our objective… until now!

Writing pixels

Let’s add a new struct ImageExporter that, using a Representation, prints pixels in a png image. Keep in mind that after drawing the pixels, the file needs to be saved with a given name in a specific folder.

// exporter.go
package main

import (
  "fmt"
  "image"
  "image/color"
  "image/png"
  "os"
  "strings"
)
// ...

type ImageExporter struct {
  representation Representation
  folder         string
  filename       string
}

func (e ImageExporter) export() (string, error) {
  width := e.representation.width()
  height := e.representation.height()
  image := image.NewRGBA(image.Rect(0, 0, width, height))
  black := color.RGBA{0, 0, 0, 255}
  white := color.RGBA{255, 255, 255, 255}
  color := black
  for y := 0; y < height; y++ {
    for x := 0; x < width; x++ {
      color = white
      if e.representation.isInside(x, y) {
        color = black
      }
      image.Set(x, y, color)
    }
  }
  // If destination folder does not exist, create it:
  if _, fErr := os.Stat(e.folder); os.IsNotExist(fErr) {
    fErr = os.MkdirAll(e.folder, 0755)
    if fErr != nil {
      return "", fErr
    }
  }
  // Create file using folder+filename, and encode the image:
  result := strings.Join([]string{e.folder, e.filename}, "/")
  f, err := os.OpenFile(result, os.O_WRONLY|os.O_CREATE, 0600)
  if err != nil {
    return "", err
  }
  defer f.Close()
  png.Encode(f, image)

  return result, nil
}

Both default Exporter and ImageExporter have the same export() method, which means that they share the same goal. With a little renaming, an interface and a new method CreateExporter, we can use any Representation of a Mandelbrot set without taking care of where it writes the result: the CLI or an image.

// exporter.go
package main
// ...
type Exporter interface {
  name() string
  export() (string, error)
}

func CreateExporter(name string, r Representation, folder string, filename string) (Exporter, error) {
  switch name {
  case "text":
    return TextExporter{r}, nil
  case "image":
    return ImageExporter{r, folder, filename}, nil
  }
  return nil, errors.New("Invalid Exporter")
}

// this is the old "Exporter"
type TextExporter struct {
  representation Representation
}

func (e TextExporter) name() string {
  return "text"
}

func (e TextExporter) export() (string, error) {
  // same code...
  return result, nil
}

type ImageExporter struct {
  representation Representation
  folder         string
  filename       string
}

func (e ImageExporter) name() string {
  return "image"
}

func (e ImageExporter) export() (string, error) {
  // same code...
}

First image

With a couple of additions in the main method, the app will be able to generate custom images of the Mandelbrot set. From now on the default output of the app will be an image, that’s why we will also have to tweak default values like width and height:

// main.go
package main

import (
  "flag"
  "fmt"
  "os"
)

func main() {
  width := flag.Int("width", 800, "width")
  height := flag.Int("height", 601, "height")
  rMin := flag.Float64("realMin", -2.0, "Min real part")
  rMax := flag.Float64("realMax", 0.5, "Max real part")
  iMin := flag.Float64("imagMin", -1.0, "Min imaginary part")
  iMax := flag.Float64("imagMax", 1.0, "Max imaginary part")
  iterations := flag.Int("iterations", 50, "Max iterations")
  expName := flag.String("exporter", "image", "exporter name")
  folder := flag.String("folder", "mandelbrot", "destination")
  filename := flag.String("filename", "", "name of the image")
  
  flag.Parse() // Don't forget this!
  
  config := Config{*width, *height, *rMin, *rMax, *iMin, *iMax}
  representation := config.representation(Verifier{*iterations})
  exporter, exporterErr := CreateExporter(
    *expName, 
    representation, 
    *folder, 
    *filename)
  if exporterErr != nil {
    fmt.Print(exporterErr)
    os.Exit(1)
  }
  result, err := exporter.export()
  if err != nil {
    fmt.Print(err)
    os.Exit(1)
  }
  fmt.Print(result)
}

Now we can build, execute and enjoy the first image generated by the app! The git checkout command is optional, in case you want to use the exact same code as me:

git checkout a37393d
go build && ./mandelbrot-step-by-step

The resulting image (mandelbrot/800x601.png) should look like this:

The Mandelbrot set, displayed in black over a white background
Behold! First image generated by the app

Testing and refactoring

Check the new tests added to the app, especially the ones related to the exporters (exporter_test.go). Working with files is tricky, that’s why the TDD approach has helped in edge cases like incorrect file extensions or directories that need to be created.

Stretched images

The generated images might look correct until you start generating them with custom ratios. For example, run the next command:

./mandelbrot-step-by-step -height=301

The resulting image displays a stretched version of the Mandelbrot set:

The Mandelbrot set, streched
Stretched version of the Mandelbrot set

To solve this problem, the app must calculate automatically the value of imagMax using the rest of the boundaries, instead of defining it manually. Let’s make some changes in the config.go file:

// config.go
//...
func CreateConfig(width int, height int, rMin float64, rMax float64, iMin float64) Config {
  iMax := iMin + (rMax-rMin)*float64(height)/float64(width)
  return Config{width, height, rMin, rMax, iMin, iMax}
}

This new method returns a Config with accurate image ratio values. For the sake of correctness, let’s modify the default parameters so that the resulting image has a 4:3 ratio:

image = 804x603 (4:3)
real part = [-2.5 .. 1.0]
imaginary length = "real length"*3/4 = 3.5*3/4 = 2.625
imaginary limit = "imaginary length"/2 = 2.625/2 = 1.3125
imaginary part = [-1.3125 .. 1.3125]

Add them to the flags in the main method:

// main.go
//...
func main() {
  width := flag.Int("width", 804, "width")
  height := flag.Int("height", 603, "height")
  rMin := flag.Float64("realMin", -2.5, "Min real part")
  rMax := flag.Float64("realMax", 1.0, "Max real part")
  iMin := flag.Float64("imagMin", -1.3125, "Min imag part")
  //iMax := we don't need it anymore
  //... 
  config := CreateConfig(*width, *height, *rMin, *rMax, *iMin)
  //...
}

These default values will return a horizontally centered image. Also, custom parameters won’t stretch the image. Get the refactored version of the app and test it by yourself!

git checkout 49dfd1b
go build && ./mandelbrot-step-by-step -height=301

Next

Now that the app generates images, the next goal is to make them more appealing by using colors. See you in the next post!