Sharing the process of writing my first Elixir program, which focuses on handling image processing tasks.
I recently started learning Elixir to learn backend development and to take on the challenge of learning a new programming language.
Elixir’s scalability and fault-tolerant capabilities, ideal for handling concurrent tasks like image processing, drew me to learn it. I was also excited that Elixir is a functional programming language.
The last time I worked with a functional programming language was when, as an undergraduate student at Cornell, I took the CS 3110 course on functional programming using OCaml.
During that period, I realized that utilizing concepts such as pattern matching and the pipe operator enhanced the readability of my code. Hence, I was looking forward to revisiting these concepts once more.
In this post, I will share my experience of putting together my first Elixir program. The objective for this program is to provide a pipeline for preprocessing photographs I’ve taken to share in the photography section of my website.
To provide some context, the photo files in question are 24-megapixel JPEG files that need to be resized to be displayed optimally on the web by using responsive features of the img
tag in HTML. I’ll also need to create webp equivalents as these tend to be more lightweight than their jpeg counterparts.
You can find the code for this project in this Github repository. As I go over my code, I will assume you are a beginner to programming in Elixir but have experience with other programming languages such as JavaScript or Python.
You can install the Elixir on macOS using homebrew by entering brew install elixir
in the terminal. For instructions on installing Elixir on other platforms visit the official website here.
With Elixir installed you should be able to run Elixir’s interactive shell in the terminal by running the iex
command in a command-line terminal.
Elixir uses the build tool mix
to handle tasks such as generating new projects with some boilerplate, apply code formatting, and handle package management for project dependencies.
We’ll use mix to create the project by running this command in the terminal
mix new image_proc
This will create a new directory named image_proc
containing some additional files and folders. These include files and directories such as
/lib
- directory where the elixir code for the project will be stored/test
- directory for storing unit tests that you can run using the testing library ExUnit.formatter.exs
- config file for the formatting tool provided by mix
which can be invoked by running mix format
mix.exs
and mix.lock
- used by mix
for managing project builds and dependenciesMore information regarding these details can be found here in the official documentation.
This project requires the Image
library which will be used as a dependency for generating resized images and converting them to other image formats.
Add {:image, "~> 0.25.1"}
to your list of dependencies in the mix.exs
file like so:
defp deps do
[
{:image, "~> 0.25.1"}
]
end
After that you’ll have to run the following command in the terminal to install this dependency:
mix deps.get
First, we’ll create the first module dedicated to handling input and output operations for image files. Create the file image_io.ex
inside the image_proc/lib
directory.
defmodule ImageIO do
end
All functions in this module go in the body of the module between the do
and end
keywords. The first function to define here will be used to load images from a given file path:
def load_image(image_path) do
image_path
|> Image.open!()
|> Image.minimize_metadata!()
end
Functions and other clauses are also wrapped using do
and end
keywords. This syntax differs from the curly braces used in languages such as JavaScript or the whitespaces used by Python.
As I’ve alluded to before, I’ve missed having access to the piping operator, given as |>
in Elixir, in other imperative languages that I use regularly, such as Python.
Piping makes it easier to compose a sequence of computations in an easy-to-read manner. Doing the equivalent in a language like Python would look something like
def load_image(image_path):
image = Image.open(image_path)
image = Image.minimize_metadata(image)
return image
While this isn’t too bad to read. When defining more complex functions this type of syntax can get a bit hairy. I also find it easier to read the Elixir equivalent better.
return
KeywordOne thing I’ve found interesting about Elixir is its conventions for naming functions.
In the load_image
definition I’ve used the open!
and minimize_metadata!
functions from the Image
library. These functions load the image from the provided file path and remove unneeded metadata from the image’s EXIF data, respectively.
In the event that an error occurs when performing these operations, an exception is raised that can be handled using the try
, catch
, and rescue
operators. These work similarly to their equivelants in other languages such as Python’s try
and except
operators.
The alternative is to use the open
and minimize_metadata
functions (named without an exclamation point). These functions will return a tuple regardless of whether they encounter an error or not.
For instance, the open
function will return the tuple {:ok, image}
if no error occurs or the tuple {:error, message}
when one does occur. This provides an opportunity to pattern match on the outputs for error handling.
It’s important to point out that the outputs :ok
and :error
, defined using a semicolon at the start of their definition, are known as atoms.
These are one of the primitive types supported by Elixir. They are distinct because they are constants whose value is the same as their name. They are very useful for implementing pattern matching structures in Elixir.
With pattern matching we could handle errors by using a case
operator as shown below:
case Image.open(image)
{:ok, image} ->
# DO something with loaded image here
{:error, message} ->
# DO something here with error and the provided message
end
The main reason to avoid doing this would be that it prevents us from using the piping operator to make code concise. You would also have to write a similar block of code to handle errors coming from Image.minimize_metadata
.
Another thing to notice here is that Elixir doesn’t have a return
keyword to break out of functions. Instead, the value of the last evaluated expression is provided as the return value for functions.
Now that we have some code written to load images we need to figure out how to retrieve the file paths to images.
Elixir’s standard library includes several packages including String
, Enum
, Path
that we can use for this purpose. These modules contain functions for handling strings, enumerated variables such as lists and maps, and file paths, respectively.
One convenient thing about iex
is that you can use it to check the functions contained in any of these modules and access documentation pertaining to their use. For instance, you can open iex
and run
iex> h File
to see the functions in the File
module which contains operations for dealing with files. If you wanted to see what File.ls
does, you would just have to run h File.ls
to get a summary of what it does and an example usecase.
We’ll define the function get_image_paths
in the Image_IO
module:
def get_image_paths(path_input_image_folder) do
path_input_image_folder
|> File.ls!()
|> Enum.filter(&is_image?/1)
|> Enum.map(fn file ->
Path.join(path_input_image_folder, file)
end)
end
First, it uses File.ls!
to list all files in the provided directory path. Next, the Enum.filter/2
function takes two arguments: a list of items and a function.
The provided function is executed on each item in the list and should return either a truthy or a falsy boolean value. If a truthy value is returned, the item is kept in the list. If a falsy value is returned, the item is removed from the list.
The paths returned by File.ls!
aren’t absolute paths. Hence, this function uses the Enum.map
function, which takes an anonymous function that uses the Path.join
function to join the paths for path of the directory given by path_input_image_folder
in front of the the names of each the individual image files.
Next, we need to define the &is_image?/1
function.
Before we get to that you may wonder what the ampersand and the /1
label means. In Elixir, the /1
refers to the function’s arity which otherwise represents the number of arguments the function accepts.
The ampersand symbol &
in &is_image
represents the capture operator in Elixir. Here, it passes the function is_image?
into the second argument of Enum.filter/2
. As an alternative you could also insert the is_image?
function by wrapping it using the fn param ->
and end
syntax like so:
path_input_image_folder
|> File.ls!()
|> Enum.filter(fn path_image -> is_image?(path_image) end)
|> Enum.map(fn file ->
Path.join(path_input_image_folder, file)
end)
The easiest way to check whether a given file is an image is to check it’s file extension to see if it corresponds to an image file format. This is precisely what the is_image?
function defined below does:
@image_filetypes [".jpg", ".jpeg"]
def is_image?(filename) do
file_extension = filename
|> Path.extname()
|> String.downcase()
Enum.member?(@image_filetypes, file_extension)
end
This function checks whether a file is an image using the @image_filetypes
module attribute. This module attribute holds a list of image file formats, specifically .jpg
and .jpeg
, which helps identify whether a given file is an image that needs processing.
Module attributes are limited to the scope within the module they are defined in, allowing us to use @image_filetypes
in any of the functions defined in the ImageIO
module. Using module attributes prevents us from having to hardcode parameters in all places that they will be used.
Finally, we’ll define a function for generating the file name of the saved image, which takes three arguments including the image name, the image’s filetype, and a prefix for the image’s name:
def get_output_filename(imagename, image_filetype, imagename_prefix) do
case imagename_prefix do
nil -> "#{imagename}.#{image_filetype}"
_ -> "#{imagename_prefix}_#{imagename}.#{image_filetype}"
end
end
This function pattern matches the variable imagename_prefix
to determine its return value. When no value is provided for this argument, the first case handles such instances by generating a string using the string interpolation syntax given by #{}
to insert the values of the variables imagename
and image_filetype
into the returned formatted string. In the catch all case given by _ ->
the generated string incorporates all three input arguments.
The next step will focus on implementing functions for processing loaded images. Create a new file called image_proc.ex
inside image_proc/lib
directory. This module, called ImageProc
, will contain the image processing functions.
This file should start off with this code
defmodule ImageProc do
end
Like before, any function definitions will go in the space between the do
and end
keywords.
First, we need to define a function that takes a path to an input folder that contains images and another path to a directory where the resized images will be saved.
If the output directory doesn’t already exist, it will be generated. Moreover, the function should iterate over the image paths and the output image filetypes to be generated. Thie process_images
functions defined below achieves this:
@imagename_prefix = ""
@output_image_filetypes ["jpeg", "webp"]
def process_images(path_input_folder, path_output_folder) do
lst_paths_image = ImageIO.get_image_paths(path_input_folder)
if not File.exists?(path_output_folder) do
File.mkdir_p(path_output_folder)
end
for path_image <- lst_paths_image, image_filetype <- @output_image_filetypes do
imagename = get_imagename(path_image)
ImageIO.load_image(path_image)
|> generate_resized_images(
image_filetype,
imagename,
path_output_folder,
@imagename_prefix
)
end
end
I appreciate the for-loop syntax in Elixir because it’s very easy to specify the conditions for a nested for-loop that loops over both the list of image paths and the list of output image filetypes, all in just one line code!
In this loop the function get_imagename
gets the name of the image file, stripping away its absolute path information and the image filetype extension, removes any whitespace, and lowercases each of the characters:
def get_imagename(path_image) do
path_image
|> Path.rootname()
|> Path.basename()
|> String.replace(" ", "")
|> String.downcase()
end
Next, once the image has been loaded the generate_resized_images
function is invoked:
def generate_resized_images(
image,
image_filetype,
imagename,
output_folder_path,
imagename_prefix \\ nil
) do
save_folder_path = Path.join(output_folder_path, imagename)
if not File.exists?(save_folder_path) do
File.mkdir(save_folder_path)
end
image_aspect = image |> Image.aspect()
long_edge_final_map = get_resized_long_edge_map(image_aspect)
image_long_edge_len = get_long_edge_len(image, image_aspect)
for {sizename, long_edge_final} <- long_edge_final_map do
scale_factor = long_edge_final / image_long_edge_len
{:ok, resized_image} = image |> Image.resize(scale_factor)
resized_imagename = "#{imagename}-#{sizename}"
resized_image
|> ImageIO.save_image(
resized_imagename,
save_folder_path,
image_filetype,
imagename_prefix
)
IO.puts("Writing image #{resized_imagename}.#{image_filetype} to #{save_folder_path}")
end
end
There’s quite a bit to unpack here. But these are the key steps:
sm
, md
, lg
and the values correspond to the length of the image’s long edge in pixels for each size once the image has been resizedIn step 2, the function Image.aspect
is used. It returns one of the following atoms depending on the input image’s aspect ratio:
:landscape
for images with a landscape aspect ratio:portrait
for images with a portrait aspect ratio:square
for images with a square aspect ratioThe aspect ratio is first used in the function get_resized_long_edge_map
to select the appropriate map depending on whether or not the image has a square aspect ratio:
def get_resized_long_edge_map(image_aspect) do
case image_aspect do
:square -> @resized_long_edge_map_square
_ -> @resized_long_edge_map
end
end
If the image has a square aspect ratio, the @resized_long_edge_map_square
mapping is used, otherwise the @resized_long_edge_map
is used.
In addition, these are the definitions of the two maps used by this function:
@resized_long_edge_map_square %{"sm" => 334, "md" => 668, "lg" => 1334}
@resized_long_edge_map %{"sm" => 500, "md" => 1000, "lg" => 2000}
For a square aspect ratio image, the script generates small, medium, and large size images with dimensions of 334x334 , 668x668, and 1334x1334, respectively.
Similarly, portrait or landscape images are resized such that their long edges are 500, 1000, and 2000 pixels long for the small, medium, and large sizes, respectively.
The only remaining helper function to define is get_long_edge_len
. This function returns the appropriate length corresponding to the image’s long edge, depending on its aspect ratio:
def get_long_edge_len(image, image_aspect) do
case image_aspect do
:portrait -> image |> Image.height()
_ -> image |> Image.width()
end
end
This is all that is needed for this script!
To run the script, first enter the interactive Elixir shell by executing iex -S mix
in your terminal.
You can then run the process_images
function by providing the path to the input image directory and the path to the output image directory as arguments:
iex> ImageProc.process_images(<path-to-input-image-directory>, <path-to-output-image-directory>)
This command will initiate the image processing workflow, resizing all images in the input directory and saving the resized versions to the specified output directory.
In this blog post, we’ve explored how to create a simple Elixir script for resizing images using the Image
library. We’ve covered the basics of image I/O, file manipulation, and aspect ratio-based resizing to generate different sizes of images. This script can be easily adapted and expanded upon to meet any other specific needs for image processing tasks.
To recap, we’ve learned how to:
Image
libraryWhether you’re working on a website, a photo management tool, or a content delivery system, this script may serve as a helpful starting point.
The image processing pipeline described above generates resized images and stores them locally on your computer. But what if you wanted to directly upload them to a cloud service like Amazon S3? To achieve this, we need to incorporate functionality that uses an S3 client to upload the resized images to a storage bucket.
In addition, our script doesn’t take advantage of Elixir’s concurrency features. In future improvements to the script, we could explore using these features, such as Tasks, to process and resize multiple images simultaneously. These improvements can significantly speed up the image resizing process, especially when working with a large number of images.
Finally, it would be beneficial to develop a user-friendly frontend, enabling users to drag and drop images for resizing, upload them to an S3 bucket, and manage S3 bucket contents. Our ultimate goal is to construct this interface using the Phoenix framework.
By implementing these improvements, we can optimize our image processing workflow, making it more efficient and user-friendly.