Build an image to ASCII art converter using Haskell
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.hs
will 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.