Hassan Uddin Developer studying computer science and mathematics, with an interest in programming languages.

Decoding and encoding images in Rust using the image crate

6 min read 1738

Decoding And Encoding Images In Rust Using The Image Crate

The safety and performance guarantees of Rust are extremely useful for developers doing all kinds of work, making it ideal for a lot of scenarios. However, Rust’s package ecosystem is extremely large and examples can be terse, oftentimes making Rust underutilized.

Rust specifically carries a lot of promise in its image manipulation abilities, but many people don’t use these features because of how daunting they seem. That’s why in this article, we’ll cover Rust’s image crate and the various methods it gives us to decode, manipulate, and encode images.

Working with images

While working with images, we want to operate on them as two-dimensional arrays. Ideally, we’d iterate over an image using a code API that looks like this:

for pixel in img.pixels() {
    // modify RBGA pixel
}

Unfortunately, most image formats are written to optimize compression and performance rather than ease of use. The PPM image format is essentially a series of ASCII RGB values, but even then it has extra data, such as a header containing height and width, that we need to account for.

From there, it gets successively more complex with formats like PNG and JPEG that add layers of compression and lossy encoding. To make matters worse, we often deal with various types of image encoding in the same program.

Attempting to operate on images using Rust’s inbuilt functionality for binary files will quickly become a headache. Here’s where the image crate comes to our rescue and gives us a very clean API to work with.

Decoding images using the image crate

The image crate has DynamicImage and ImageBuffer enums that allow us to operate over images with an API, like in our example from the previous section.

ImageBuffer represents a specific type of image that has essentially been converted to a 2D array, while DynamicImage allows us to move between types of images and still provides some general image processing abilities.

To create either one of these, the crate gives us a few utilities for opening images and decoding them. Let’s test them out on our mascot, Ferris the crab.

Ferris The Crab Rust Programming Language's Official Mascot
Ferris the Crab, the Rust programming language’s official mascot.

image::open(path)

If we just want something quick and easy, we can use image::open(path). This is a top-level method in the crate that takes in an argument satisfying AsRef<Path> and returns a Result containing a DynamicImage. Since String and str implement AsRef<Path>, we can use it like this:

use image;
use image::GenericImageView; // to allow calling .pixels()
// ...
let img = image::open("ferris.png").expect("File not found!");
for pixel in img.pixels() {
    // modify RGBA pixel
}

The function will assume encoding from the file extension that we give it, and that’s good enough for most purposes.

image::io::Reader

The image crate also allows us to have more fine-grained control over how we load images. For example, what if we are loading an image over a network connection or from the GPU and receive it as a buffer rather than dealing with a file on disk? Or, what if we don’t trust the file extension that the user has given us?

For more detailed cases like these, we can use image::io::Reader. This method gives us control over the loading process.

For example, given a PPM image in memory, we can load it in as such:

use image::io::Reader;
use std::io::Cursor;

let data = Cursor::new(b"\
P3 3 2 255
255   0   0     0 255   0     0   0 255
255 255   0   255 255 255     0   0   0");
let reader = Reader::new(data)
    .with_guessed_format()
    .expect("This will never fail using Cursor");

let img = reader.decode().expect("Failed to read image");

Let’s step through this example piece by piece. At the very beginning, we create a Cursor object to represent a binary stream of data directly from a string. In our case, this is just a simple test image, but in reality, this may come from a network socket, the GPU, or some other form of communication.

Once we’ve constructed a reader object, we need to figure out the image format of our stream.



with_guessed_format

The with_guessed_format method tries to read from the stream and use the header to determine its data type, which, in our example, is P3 for the PPM format. Since our Cursor will always be readable, this method will never fail and we can safely call expect. If we give it some other type that implements Read and fails, it will return an error type.

decode

So far, we have provided the reader with information about the image we want to read, such as what type of image it is. To get an image out of the reader, we need to call decode, which returns a Result containing our DynamicImage from earlier. Decode can break in two ways:

Decode with an unsupported image format

We will trigger an error if we fail to determine the image format when calling with_guessed_format. The format of the reader object will become an error type.

We can specifically check for this by either pattern matching the return of decode against an ImageError::Unsupported, or by checking that reader.format() returns a Some value before we call decode.

Decode on a bad image

We can also trigger an error if the image does not line up with the format we have specified. For example, the PPM file format indicates each pixel as chunks of three ASCII values representing RGB. If, for some reason, we only had two values per pixel, decode would return an ImageError::DecodingError.

Reader::open

If we wanted to take advantage of the more advanced formatting of Reader but still open files as we were doing before, we can call Reader::open. This method assumes the image format from the file extension, but we can call with_guessed_format() and have it read the header to infer the actual image format.

Encoding images using the image crate

Now we’ll discuss encoding images. The image crate makes this simple, but there are a few caveats to be aware of. We’ll go over operating on images as well as saving them.

Operating on images

Modifying the images we load is as easy as reading and writing images, with a few caveats.

For a lot of common operations, like blurs or filters, the crate provides us with a set of inbuilt operations. A full list can be found here, but what if we wanted to modify an image ourselves?

We need to be careful about how we are operating on the image in memory. Although it is possible to modify the image as we iterate over it, we will more often than not run into issues with the borrow checker.

If we try writing to the image while iterating over it, we might end up modifying something we’ll iterate over later and open a whole can of worms. Instead, it’s generally simpler to make a copy of the image that we write to instead.

As an example, suppose that we want to iterate over our friendly ferris.png image and make every pixel closer to black. The image crate provides a utility to do this, but let’s write a simple version ourselves:

use image::{GenericImageView, ImageBuffer, Pixel};

fn main() {
    let img = image::open("ferris.png").expect("File not found!");
    let (w, h) = img.dimensions();
    let mut output = ImageBuffer::new(w, h); // create a new buffer for our output

    for (x, y, pixel) in img.pixels() {
        output.put_pixel(x, y, 
            // pixel.map will iterate over the r, g, b, a values of the pixel
            pixel.map(|p| p.saturating_sub(65))
        );
    }
    // What do we do with output now? 
}

Now, Ferris will look like this:

Ferris After We've Made All Of His Pixels Darker
Ferris after we’ve made all of his pixels darker.

In just a few lines of code, we can read in a PNG, iterate over its internal pixels, and create a new version of it. This basic structure should be everything we need to write any image processing algorithm in Rust.


More great articles from LogRocket:


Now that we have created a new image, how do we get it out of memory and send it somewhere? That’s where writing an image comes in.

Writing an image

Once we’ve loaded and modified an image or created a completely new image from scratch, we can use the image crate to encode and save it.

Saving to a file is extremely easy. Both DynamicImage and ImageBuffer have a save(path) method. They’ll infer the format that we wish to encode the image in using the file extension.

After making all of our desired modifications, we can do something like img.save("output.png") and the crate will handle all of the hard work for us.

For most use cases, saving to a file is sufficient. Sometimes, however, we want to send the encoded image over a network connection or send an image to some hardware component. In cases like these, we’d prefer to write our encoded image instead.

For this use case, write_to is our friend. write_to is available both for DynamicImage and any type of ImageBuffer. It will take a writer object that implements Write + Seek, as well as an ImageOutputFormat object, which comes as part of the crate.

Conclusion

The image crate is an extremely powerful tool that gives us a lot of fine-grained control over our images and how to manipulate them. It also provides tons of utilities and features that make image processing simple.

If you’ve found that the image crate is slow to process your images, make sure that you are running your code in release mode using cargo run --release.

A lot of the methods we’ve used will be running multiple loops over your images, and Rust maintains a lot of extra unnecessary debugging information when it’s run without the release flag. If you’re creating a tool and want to test it with debugging information available, I would recommend working with smaller images.

We’ve covered a lot of information about decoding and encoding using the image crate! You should now have the understanding needed to go forward and work with images in Rust.

LogRocket: Full visibility into production Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Modernize how you debug your Rust apps — .

Hassan Uddin Developer studying computer science and mathematics, with an interest in programming languages.

Leave a Reply