I wrote my first image processing application in Haskell yesterday based on a tutorial by Mark Karpov. It writes a Mandelbrot fractal to an image file like this:
The library I used is called JuicyPixels, which you can install with cabal like this:
cabal install JuicyPixels
It also depends on the Safe library, just because I like the Maybe variant of the head function. You can install that like this:
cabal install Safe
Let’s walk through the code.
The main function is pretty simple. I avoided using do notation so that it’s really clear what’s going on with the monadic binding:
main = let imageWidth = 1920 imageHeight = 1080 fractal = mandelbrot (Counter 1000) (Dimensions (Width imageWidth) (Height imageHeight)) in getArgs >>= (\args -> printFileName (headMay args)) >>= (\fileName -> return (fileName, (ImageRGB8 (generateImage fractal 1920 1080)))) >>= (\(fileName, image) -> savePNGFile fileName image) >> exitSuccess
First, I define an imageWidth and imageHeight. Then I define a fractal function by calling the mandelbrot function. I bind the command line arguments to args and maybe print the file name which should be stored in the head of the arguments list. Because I use headMay instead of head, the fileName is stored in a type Maybe String, instead of String, which means that if a command line argument is not provided, the program does nothing except print a usage string.
Secondly, I lift a tuple of the fileName and an image generated by the function mandelbrot into the IO monad and bind that to the next statement, which saves the image file. Finally, I use a >> bind to continue execution with exitSuccess, which exits with a success code.
>> bind operations allow you to call a monadic function which doesn’t take a parameter. Since exitSuccess doesn’t take a parameter, we need to use >> bind here.
Let’s look quickly at the printFileName function:
printFileName :: Maybe String -> IO (Maybe String) printFileName Nothing = putStrLn "Usage: fractal " >> return Nothing printFileName (Just fileName) = putStrLn ("Generating " ++ fileName) >> return (Just fileName)
This function takes the output of headMay as an input.
In the case where it pattern matches Nothing, it prints a usage string. It needs to evaluate to an IO (Maybe String) because that is later bound to the fileName constant, so I lift Nothing into the IO monad after printing.
In the case where it pattern matches Just fileName, I print a string and bind that to an expression which lifts Just fileName into the IO monad, so that it can be bound to fileName later.
generateImage is a function provided by the JuicyPixels library which creates a function by mapping a function over an ADT (presumably a list). The function I wrote for it to map is mandelbrot. The Mandelbrot function is defined as:
mandelbrot :: Counter -> Dimensions -> Int -> Int -> PixelRGB8 mandelbrot (Counter maxIter) (Dimensions (Width w) (Height h)) x y = let maxDimension = max w h --Coordinates recentered so that the origin is in the middle of the image Coordinate rx ry = Coordinate (x - w `div` 2) (y - h `div` 2) --Coordinates normalized into the range [-1, 1] Point nx ny = Point (fromIntegral rx / fromIntegral maxDimension) (fromIntegral ry / fromIntegral maxDimension) pixelValue = genMandelbrot (Counter 0) (Counter maxIter) (Point 0.0 0.0) (Point (nx * 3.0) (ny * 3.0)) --Scale point to the range [-3, 3] in PixelRGB8 0 0 (fromIntegral (min pixelValue 255))
mandelbrot takes a counter, image dimensions and two integers representing the texel coordinates, and evaluates to the texel value. The Counter defines the maximum number of iterations of Mandelbrot’s function to run. Notice how I used Currying and partial evaluation to pass a valid function, mandelbrot (Counter 1000) (Dimensions (Width imageWidth) (Height imageHeight)), to generateImage, which expects to get a function with two Int parameters as an argument.
The let expression computes the pixel value.
We need the maxDimension to ensure that the fractal fits inside the image.
Coordinate rx ry re-centers the pixel coordinate from a range of ([0, width], [0, height]) to a coordinate in the range ([-width/2, width/2], [-height/2, height/2]) so that the center of the fractal, (0, 0), will be in the middle of the image.
Point nx ny converts the re-centered coordinates to the range ([-1, 1], [-1, 1]) so that the fractal will be scaled to fill the image correctly.
When computing the final texel value in genMandelbrot, nx and ny are scaled by 3.0 because that looks prettier.
This function calls the pure implementation of the Mandelbrot function:
genMandelbrot :: Counter -> Counter -> Point -> Point -> Int genMandelbrot (Counter iter) (Counter maxIter) (Point x y) (Point px py) | diverges || iter > maxIter = iter | otherwise = genMandelbrot (Counter (iter + 1)) (Counter maxIter) (Point updatedX updatedY) (Point px py) where diverges = x * x + y * y > 4.0 --Update coordinates based on the definition of the Mandelbrot Set updatedX = x * x - y * y + px updatedY = 2.0 * x * y + py
This function recursively re-evaluates a pair of local coordinates, Point x y, based on the pixel coordinates, Point px py, by repeatedly applying the fractal iteration. If the coordinates diverge (distance squared > 4) or the function exceeds the maximum iterations, it evaluates to the iteration count, which determines the pixel color.
fractal.hs produces a simple blue Mandelbrot fractal. Later, I wrote coloredfractal.hs which was used to generate the image above using the discrete Escape Time algorithm to color the fractal.
Special thanks to u/jared–w who gave me feedback and suggested improvements to my code and @geophf for suggesting that I should use the Escape Time algorithm to color the fractal.
The source code is available at https://github.com/WhatTheFunctional/ImmutableMandelbrot.
Image processing with Juicy Pixels and Repa by Mark Karpov