On demand image processing with Haskell

1 September 2016 by Mats Rietdijk

Images have become an important part of modern web design. They draw attention, trigger emotions and could make for the perfect atmosphere on a website. You could just serve the original image and use CSS to display it as you see fit. However this does impact the load time for your webpage and uses unnecessarily much bandwidth. So having your images available in the right size would be best for you and your visitors.

How?

There are a couple of packages available to do image processing in Haskell, for this article we will be using JuicyPixels and friday with the bridge package friday-juicypixels. We will combine those packages with scotty to create a small web-application which will respond to routes with the following format: /images/:size/:file where :size is a predefined named size and :file the name of a file available on the filesystem.

Internally this application will check if a cached version of the requested size already exists and serve this to save time. However is a cached version does not exist it will look up the original, crop it to the required dimensions and then resizes it to match the requested size and cache the newly computed image.

We will only handle jpeg images in this article but with a bit of tinkering it should not be hard to support other formats too.

Setup

Let's create a simple Haskell application with stack, create a folder to store our images in and add the applications dependencies to the .cabal-file.

stack --version #> Version 1.1.2 x86_64 hpack-0.14.0 stack new on-demand-images simple --resolver lts-6.14 cd on-demand-images mkdir images

Beside the image processing and web-application dependencies we will also be using:

The build-depends should look like this:

build-depends: base >= 4.7 && < 5 , JuicyPixels , directory , flow , friday , friday-juicypixels , scotty , text

Because friday and friday-juicypixels are not available on Stackage we need to add them in stack.yml as extra-deps. They also require an other dependency which is not included in Stackage, which is ratio-int, so add that one too.

extra-deps: - friday-0.2.2.0 - friday-juicypixels-0.1.1 - ratio-int-0.1.2

All of those dependencies should be imported in src/Main.hs. I like to import most dependencies qualified so it's always clear from which package a function comes and it also avoids naming collisions, but feel free to do it your own way.

import qualified Codec.Picture as JP import qualified Codec.Picture.Types as JP import qualified Control.Monad.IO.Class as IO import qualified Data.Text.Lazy as Text import Flow import qualified System.Directory as Dir import qualified Vision.Image as Friday import qualified Vision.Image.JuicyPixels as Friday import qualified Vision.Primitive as Friday import qualified Web.Scotty as Scotty

For convenience we also insert OverloadedStrings and RecordWildCards at the beginning of src/Main.hs.

{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-}

Defining available sizes

For this article we will keep it simple and allow the application to respond to two sizes: Thumbnail and Header. However it will be very easy to add new sizes whenever you want to. We want those sizes to be type-safe so let's define them as data. I also added and derived Enum and Bounded instance because those will be needed later on.

data Size = Thumbnail | Header deriving (Enum, Bounded)

Now it would be nice to actually give those Size's some dimensions to work with. So why not create a function dimensions which returns a Tuple with the width and height for the size. While we are at it we might just as well create a width and height function too.

-- dimensions given as (width, height) dimensions :: Size -> (Int, Int) dimensions Thumbnail = (200, 200) dimensions Header = (600, 300) height :: Size -> Int height = snd . dimensions width :: Size -> Int width = fst . dimensions

Because we need to create a cache directory for each image size and we want scotty to read the size from the url we have to define the slugs for all our sizes. We could just derive Show on Size but that would result in the urls having a capitalised path piece which I don't like so we will define the Show instance manually and use this as input for the Scotty.Parsable instance which scotty requires.

instance Show Size where show Thumbnail = "thumbnail" show Header = "header"

The Scotty.Parsable Size instance requires the parseParam function to be implemented which should return an Either Text Size where the Text is a custom error message. To keep this dynamic we will use the Show and Bounded instances of Size so we will not have to touch this piece of code ever again! The Bounded class exposes a minBound and maxBound function which we can use to create a list of all available sizes without having to write them all out again. With this list we create a dictionary from which we can lookup the actual type for a size slug. If the lookup succeeds we return the type and otherwise we return the error message Invalid size.

instance Scotty.Parsable Size where parseParam size = maybe (Left "Invalid size") Right $ lookup size sizes where sizes :: [(Text.Text, Size)] sizes = map (\s -> (Text.pack $ show s, s)) [minBound..maxBound]

Adding a new size would be as easy as adding it to the data type, define the dimensions and add the slug to the Show instance.

Building the route handler

Our application will just handle a single route (/images/:size/:file), so we are going to use a minimal scotty implementation which is going to run the application on port 3000. Because this is the only thing our application is going to do we will put this directly in the modules main function. The handler will retrieve the :size and :file parameters from the url, check if an original exists for the requested image, check the cache and when required generate the new image size. When scotty fails to read the size from the url it will stop the execution of the handler and return a 404. If anything unexpected happens while reading or creating a cached image a 500 with the error message Could not process the requested image is returned.

main :: IO () main = Scotty.scotty 3000 $ do Scotty.get "/images/:size/:file" $ do size <- Scotty.param "size" file <- Scotty.param "file" requireOriginal file p <- IO.liftIO $ readOrCreateCachedImage size file case p of Just path -> Scotty.file path Nothing -> Scotty.raise "Could not process the requested image"

Next we are going to perform some actions on the filesystem so let's define some helpers to avoid mistakes. Defining the path structure for our original images and the cached sizes should be enough.

imgDir :: FilePath imgDir = "./images/" sizeDir :: Size -> FilePath sizeDir s = imgDir ++ show s ++ "/"

Now it's time to implement the requireOriginal function which we already used in the route handler. All this function should do is check if an original exists for the requested image, if an original is not present it should stop the execution of the handler (which results in a 404).

requireOriginal :: FilePath -> Scotty.ActionM () requireOriginal file = do doesExist <- IO.liftIO . Dir.doesFileExist $ imgDir ++ file if doesExist then return () else Scotty.next

Having made sure we only ever try to handle sizes for images that have an original we should now implement readOrCreateCachedImage. This function will check if a cached version of the image already exists for the requested size. If the cached image exists we return the path on the filesystem where it's stored and otherwise try to generate it. When generation succeeds continue by also returning the path, on a failure we return Nothing.

readOrCreateCachedImage :: Size -> FilePath -> IO (Maybe FilePath) readOrCreateCachedImage size file = do doesExist <- Dir.doesFileExist path if doesExist then return $ Just path else do generated <- generateSize size file return $ if generated then Just path else Nothing where path = sizeDir size ++ file

Processing images

That leaves us with just the core part of the application to build; the actual processing of the images. Because we went 100% Haskell we use JuicyPixels to read the original image from the filesystem and then convert it to something friday understands. friday on its turn performs the required computations on the original to get to the requested image size before converting it back for JuicyPixels to save the cached version.

In readOrCreateCachedImage we already saw the generateSize function. This function starts by reading the original image (which should exist, we checked this). Remember we currently only support jpeg files so placing an image with a different format in ./images/ will result in an error. If the folder for the requested size does not yet exist we should also create it here. To crop the image we need to calculate a rectangle, this is the most complex part of the application and we will get to this in a moment. After cropping its just a case of resizing the image to the size's dimensions and save it. Note that we use the heaviest resize method and save the jpeg with the highest possible quality, faster (first time) responses and smaller file sizes to save additional bandwidth can be achieved by playing with those settings. To tell the caller of generateSize whether the processing was successful or not we let it return a Bool.

generateSize :: Size -> FilePath -> IO Bool generateSize size file = do o <- JP.readJpeg $ imgDir ++ file case o of Right (JP.ImageYCbCr8 image) -> do Dir.createDirectoryIfMissing False $ sizeDir size image |> JP.convertImage |> Friday.toFridayRGB |> Friday.crop (rectFor size image) |> Friday.delayed |> Friday.resize Friday.Bilinear (Friday.ix2 (height size) (width size)) |> Friday.toJuicyRGB |> JP.ImageRGB8 |> JP.saveJpgImage 100 (sizeDir size ++ file) return True _ -> return False

Now for the cropping rectangle we need to know if we have to adjust the width or the height of the original. We can find this out by calculating the target and source ratios and comparing them. When calculation the ratio by dividing the height with the width a bigger target ratio means we have to adjust the width. We are also going to center the crop so an adjustment to the width will mean a shift on the x-axis and thus we need to shift the y-axis when we make adjustments to the height. The amount of pixels we need to shift will always be the amount of pixels which are going to be cropped off divided by 2. Note that we first need to convert the widths and heights to Doubles before doing the calculations or we lose a lot of important precision.

rectFor :: Size -> JP.Image a -> Friday.Rect rectFor size JP.Image{..} = if targetRatio > srcRatio then Friday.Rect { rX = (imageWidth - newWidth) `div` 2 , rY = 0 , rWidth = newWidth , rHeight = imageHeight } else Friday.Rect { rX = 0 , rY = (imageHeight - newHeight) `div` 2 , rWidth = imageWidth , rHeight = newHeight } where f :: Int -> Double f = fromIntegral targetRatio = f (height size) / f (width size) srcRatio = f imageHeight / f imageWidth newWidth = floor $ (f imageHeight / f (height size)) * f (width size) newHeight = floor $ (f imageWidth / f (width size)) * f (height size)

Seeing results

It's about time we get to build the application and see it in action.

stack exec on-demand-images

The application is now running on port 3000. Put some jpg images in ./images/ and let the application create the image sizes dynamically whenever they are required by visiting https://localhost:3000/images/<header|thumbnail>/<your file name>.jpg.

The source code for this article is available on https://gitlab.com/paramander/on-demand-images.