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 as 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 Double
s 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.