Skip to content

Latest commit

 

History

History
410 lines (335 loc) · 12 KB

2018-09-20-mandelbrot-image.md

File metadata and controls

410 lines (335 loc) · 12 KB
layout title date categories tags abstract image image-alt image-source image-caption published
post
The Mandelbrot set, step by step (3): image creation
2018-09-20 05:00:00 +0700
posts
mandelbrot golang
Let's improve the output of the app by generating black & white images of the set
/images/posts/2018-mandelbrot-image.jpg
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
true

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][requirements]. If you want to see the code at this point of the history, [browse the original repository][browse-repo-start] or execute the next command to retrieve the code:

{% highlight shell %} git checkout 85e9d1b {% endhighlight %}

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:

{% highlight golang %} // 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] } {% endhighlight %}

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:

{% highlight golang %} // 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 } {% endhighlight %}

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:

{% highlight golang %} // 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 } {% endhighlight %}

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

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

fmt.Print(exporter.export()) } {% endhighlight %}

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.

{% highlight golang %} // 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 } {% endhighlight %}

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.

{% highlight golang %} // 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... } {% endhighlight %}

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:

{% highlight golang %} // 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) } {% endhighlight %}

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:

{% highlight shell %} git checkout a37393d go build && ./mandelbrot-step-by-step {% endhighlight %}

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][browse-repo-exporter-test] (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:

{% highlight shell %} ./mandelbrot-step-by-step -height=301 {% endhighlight %}

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:

{% highlight golang %} // 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} } {% endhighlight %}

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:

{% highlight shell %} image = 804x603 (4:3) real part = [-2.5 .. 1.0] imaginary length = "real length"3/4 = 3.53/4 = 2.625 imaginary limit = "imaginary length"/2 = 2.625/2 = 1.3125 imaginary part = [-1.3125 .. 1.3125] {% endhighlight %}

Add them to the flags in the main method:

{% highlight golang %} // 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) //... } {% endhighlight %}

These default values will return a horizontally centered image. Also, custom parameters won't stretch the image. Get the [refactored version of the app][browse-repo-correct-ratio] and test it by yourself!

{% highlight shell %} git checkout 49dfd1b go build && ./mandelbrot-step-by-step -height=301 {% endhighlight %}

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!

[requirements]: {% post_url 2018-09-11-mandelbrot-cli %}#requirements [browse-repo-start]: https://github.com/rmhdev/mandelbrot-step-by-step/tree/85e9d1b45574827bb41ba7efddc9c5117584b1f9 [browse-repo-exporter-test]: https://github.com/rmhdev/mandelbrot-step-by-step/blob/a37393d0546c6f07a631494029bd776dd83d18f5/exporter_test.go [browse-repo-correct-ratio]: https://github.com/rmhdev/mandelbrot-step-by-step/tree/49dfd1bc1949dd9da9eb89a7c79efe65dc190f5b