Build an image to ASCII art converter using Haskell

Andrei Lozhkin
AnyMind Group
Published in
5 min readJan 9, 2023
Converted version of original image https://pixabay.com/photos/tiger-predator-fur-beautiful-2514210/

Converting an image to ASCII art is a fun and creative way to represent an image using only text characters. This article will show how to create a simple program that converts an image to ASCII art written in Haskell, using the HIP (Haskell Image Processing) library.

Check online demo here: https://lozhkinandrei.github.io/image-ascii/.

If you don’t have Haskell toolchain installed yet (GHC, stack, cabal), you can check this tutorial https://betterprogramming.pub/haskell-vs-code-setup-in-2021-6267cc991551, which also contains VS code setup instructions.

1. Create a new stack project

To create a new stack project, just run the following command in your terminal stack new image-ascii . Once the project is generated, add the necessary packages in package.yaml dependencies section:

dependencies:
- base >= 4.7 && < 5
- hip
- vector

I had an issue on Mac m1 with the newest version of GHC, so I changed it to a slightly older in stack.yaml resolver section:

resolver:
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/nightly/2022/11/12.yaml

Now we can run stack build to see if the project compiles without any issues.

2. Constants

Firstly, we create a characters map to be used for mapping pixels to characters.

asciiCharactersMap :: String
asciiCharactersMap = "`'.,^:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"

These characters are ordered based on the occupied area on the screen. For example, ` uses much less space than $.

Secondly, we need to find the max brightness of the pixel. We will calculate brightness using the greyscale formula 0.2126 * red + 0.7152 * green + 0.0722 * blue, the higher the value, the brighter the pixel is, for example, a white pixel will be 255 and a black one 0.

maxBrightness :: Int
maxBrightness = 255

And lastly, we will find a brightness weight by dividing the length of our ASCII characters string by the maximum brightness value.

brightnessWeight :: Double
brightnessWeight = fromIntegral (length asciiCharactersMap) / fromIntegral maxBrightness

The end file src/AsciiConverter/Const.hs will look like this

module AsciiConverter.Const
(
asciiCharactersMap,
maxBrightness,
brightnessWeight
) where


asciiCharactersMap :: String
asciiCharactersMap = "`'.,^:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"

maxBrightness :: Int
maxBrightness = 255

brightnessWeight :: Double
brightnessWeight = fromIntegral (length asciiCharactersMap) / fromIntegral maxBrightness

3. Converter logic

Firstly, we will add a function to resize an image to a specified width, it accepts 2 parameters width and img, and returns resized image.

resizeImage :: Array arr cs e => Int -> Image arr cs e -> Image arr cs e
resizeImage width img = scale Bilinear Edge (scaleFactor, scaleFactor) img
where
currentWidth = I.cols img
scaleFactor = fromIntegral width / fromIntegral currentWidth :: Double

Secondly, create a function that converts a pixel to an ASCII character based on its index.

replacePixel :: Pixel RGB Double -> String
replacePixel (PixelRGB r g b) = character
where
red = r * 255 :: Double
green = g * 255 :: Double
blue = b * 255 :: Double
i = floor $ (0.2126 * red + 0.7152 * green + 0.0722 * blue) * brightnessWeight :: Int
character = [asciiCharactersMap !! i]

We will also need some helper function to insert breaks \n after n characters.

insertAtN :: Int -> a -> [a] -> [a]
insertAtN 0 _ xs = xs
insertAtN _ _ [] = []
insertAtN n y xs
| length xs < n = xs
| otherwise = take n xs ++ [y] ++ insertAtN n y (drop n xs)

The actual conversion function accepts image and config as parameters and returns a string with breaks inserted, for example :[-m?+Q^,:\n~+Y:i:x1_,\nvuj]hQU{):\na00w?Lvx+1\nfu@~n.i(^U\nU’^,’lI/zc\nCvuxcUU0YX\n

convertToAscii :: Image VS RGB Double -> Config -> IO String
convertToAscii img config = do
let pixelsVector = toVector img
let pixelsList = toList pixelsVector :: [Pixel RGB Double]
let converted = Data.List.map replacePixel pixelsList
let withLineBreaks = insertAtN (imageWidth config) "\n" converted
return $ concat withLineBreaks

Where Config is just a data type that holds the information needed for the conversion:

data Config = Config
{ imageWidth :: Int
, imageColor :: Bool
}

The final file src/AsciiConverter/Lib.hswill look like this:

module AsciiConverter.Lib
(
Config (..),
resizeImage,
convertToAscii,
) where

import AsciiConverter.Const ( asciiCharactersMap, brightnessWeight )

import Data.List ( map )
import Data.Vector.Storable ( toList )

import Graphics.Image as I
import Graphics.Image.Interface ( Array (toVector) )

import Prelude


data Config = Config
{ imageWidth :: Int
, imageColor :: Bool
}


resizeImage :: Array arr cs e => Int -> Image arr cs e -> Image arr cs e
resizeImage width img = scale Bilinear Edge (scaleFactor, scaleFactor) img
where
currentWidth = I.cols img
scaleFactor = fromIntegral width / fromIntegral currentWidth :: Double

replacePixel :: Pixel RGB Double -> String
replacePixel (PixelRGB r g b) = character
where
red = r * 255 :: Double
green = g * 255 :: Double
blue = b * 255 :: Double
i = floor $ (0.2126 * red + 0.7152 * green + 0.0722 * blue) * brightnessWeight :: Int
character = [asciiCharactersMap !! i]

convertToAscii :: Image VS RGB Double -> Config -> IO String
convertToAscii img config = do
let pixelsVector = toVector img
let pixelsList = toList pixelsVector :: [Pixel RGB Double]
let converted = Data.List.map replacePixel pixelsList
let withLineBreaks = insertAtN (imageWidth config) "\n" converted
return $ concat withLineBreaks

insertAtN :: Int -> a -> [a] -> [a]
insertAtN 0 _ xs = xs
insertAtN _ _ [] = []
insertAtN n y xs
| length xs < n = xs
| otherwise = take n xs ++ [y] ++ insertAtN n y (drop n xs)

4. Try it out

In the app/Main.hs add the following code, it simply reads an image from the specified path, resizes it, converts to ASCII, and prints to the terminal.

module Main where

import Control.Monad.IO.Class
import AsciiConverter.Lib ( Config (Config, imageColor, imageWidth), convertToAscii, resizeImage)
import Graphics.Image ( readImage )


main :: IO ()
main = do
let config = Config{imageWidth = 100, imageColor = False}
image <- readImage "yourimage.jpg"

case image of
Left _ -> putStrLn "Couldn't read the image"
Right img -> do
let resizedImg = resizeImage (imageWidth config) img
converted <- liftIO $ convertToAscii resizedImg config
putStrLn converted

Replace yourimage.jpg with your image path, rebuild the project stack build, and run it stack exec image-ascii-exe. If you create a new terminal profile with a smaller font size, and line spacing of around 0.6, the end result in your terminal will look like this.

5. Disclaimer

This is a part of a simple web app, the end goal of which was to try deployment of the Haskell app to Cloud Run from CI/CD pipeline utilizing multistage docker build and caching. So the code quality wasn’t the priority at this stage.

Source code: https://github.com/lozhkinandrei/image-ascii.

--

--