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.
In this post we will improve the app to generate black & white images of the Mandelbrot set.
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 %}
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 %}
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!
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 %}
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:
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.
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:
Stretched version of the Mandelbrot setTo 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 %}
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