Getting a bit Rusty

Stepan Khodzhaian
Carwow Product, Design & Engineering
6 min readApr 18, 2019

--

I’m not writing in Rust on a daily basis, but I have been very excited about it for a while. Rust is an extremely performant, powerful, and quite elegant language.

And although I’m a beginner in Rust, I would like to share the process behind a toy application that I developed recently. It’s a very simple command line tool that prints an image file using ASCII symbols directly to your terminal. (You can find it here https://github.com/mightykho/rascii)

We’ll start with the most basic use case — static image input and ASCII output — and move towards coloured output and gif animation, adding better abstractions along the way.

So let’s talk about what we’re trying to achieve. What would be the input and the output of our application? I would expect a user to specify a path to an image and receive an output that consists of ASCII characters and looks similar to the provided image.

Example of input and output of ASCII rendering

Setting up a new project

So without further ado let’s just jump straight to it. First, make sure that you have Cargo installed. Then we need to create a new Rust project:

$ cargo new ascii_renderer; cd ascii_renderer

and open the Cargo.toml file. This file contains information about our package and its dependencies. For now, we need only two:

  1. A neat package called Clap that helps you to handle command line arguments. We need this since we are making a command line tool.
  2. And a very powerful package called Image that — surprise, surprise — handles image processing for you.

So as a result our [dependencies] section in Cargo.toml should look something like this:

Notice that we specified the yamlfeature for clap. That will allow us to create a cli.yml file that will contain all app and argument information we will provide to our user.

Let’s build our package to make sure everything gets installed properly

$ cargo build

After it’s done we’ll create our src/cli.yml.

That basically says that our application requires one argument and it’s gonna be an “image”.

Next open src/main.rs (This is the main entry point for any Rust application) and let’s tell Rust to actually use our cli.yml.

Let’s take a look at what’s happening here…

On the first two lines, we’re telling Rust that we want to use an external macro from the Clap crate. If you’re not familiar with macros, they are essentially pieces of code that write code. During compilation, the preprocessor will replace each macro call with its code, which later will be compiled during the main compilation process. Since macro expansion happens before static type checks, they allow a bit more flexibility than functions.

On lines 5 and 6 we use Clap’s macro to load our yaml file and then get arguments passed to our application. Next, we get image_path argument. Notice the unwrap at the end; we use it because according to Clap’s documentation, value_of method returns Option<&str>. The Option type is an enum that contains either Some(value) or None. The thing is, Rust doesn’t have a concept of null (nil), so every method that could potentially return nothing an optional value.

There are a lot of cool tricks on how to safely get the raw value out of Option. Unwrap method is not one of them :) If the value of image_path is None our application would crash, but we are safe to use it here since Clap library makes sure that image_path has been passed (since it is marked as required) even before we try to get its value.

Then we just use the println! macro to output the image_path value to the console.

Let’s run it…

$ cargo run test.png
Finished dev [unoptimized + debuginfo] target(s) in 0.07s
Running `target/debug/ascii_renderer test.png`
Passed image path is test.png

All seems about right but not very exciting.

Resizing an image to the terminal output size

Let’s think of what we want to do next. For now, I would imagine that we want to have an output of specific width (let’s say 100 characters), and height that is calculated based on the aspect ratio of the image. We have image dimensions and we have output width. What we need to calculate is the output height. To calculate the output height, we use a formula that looks like:

output_height = (img_height / img_width) * output_width

Next, we want to resize our image to output_width × output_height, so for each pixel we would have one character. Then we want to iterate over the pixels and print them out to the terminal. For now, we’ll output any symbol for each pixel just to check that everything works as intended.

We start by bringing in the image::GenericImageView trait to the scope, so that we can get the width and height of our image. Traits are used to create shared functionality between multiple structs. In order to use that shared functionality, you need to make sure that you imported it into your file.

Then we define a OUTPUT_WIDTH constant. (you have to be explicit about the type of the constant)

After that we open an image (again None value has been handled by image), then we get the image’s width and height to calculate aspect ratio. Notice that we convert the dimensions to f64 in order to get a floating point aspect ratio value. Then we use that value to get output height, which is stored as u32. We use output_height and output_width to create a resized version of the image.

Next, we define a mutable cur_y variable that represents the current pixel row we are working on. Notice that we have to use the keyword mut. By default, all variables in rust are immutable, which means that you can’t change them unless they’re marked this way.

Then we convert our thumbnail to a RGBA pixel buffer and iterate over its pixels. Every iteration gets a tuple of three elements (x, y, pixel) which represent the coordinates and RGBA colour of the pixel.

If the value of the current pixel row is different from the y coordinate of the pixel, we print a newline and reassign the value of cur_y. As a starting point, we will output an asterisk for each pixel.

Let’s give it a spin…

$ cargo run 1.png

Again our output doesn’t look very exciting.

********************************************************************
********************************************************************
********************************************************************
********************************************************************
********************************************************************
********************************************************************
********************************************************************
********************************************************************
...

Also, it looks way too tall. Don’t worry, we will solve that later.

Turning pixels into characters

Next, let's define a “palette” of characters that we’re gonna use for our colour representation. I recommend using these characters .:-=+*#%@ since they work very well as a monochrome spectrum (You might want to reverse that list if you use a bright theme in your terminal). Then we need to pick the corresponding character for each pixel, based on its brightness. Also, we want to desaturate our image first to even values of R, G, and B (which are represented as u8 (0..255) value).

Then we use a simple formula to get the index of the character in our list:

color_value = any_color / 255.0;
char_index = color_value * (char_list_length - 1);

And then we take that character and print it out to the console.

The result should look much better now, but still way too stretched vertically.

The reason for this is that pixels are squares in regular images, but the characters in our ASCII art take more vertical space depending on the line height you use. Our characters have their own aspect ratio, so we need to use it when we calculate the image aspect ratio. This value will be different depending on your fonts settings, but for me, it’s 2.75.

Applying those changes we get:

and my final output looks like this:

Make sure that you run cargo build --release to build your binary without debugging functionality. That will make it multiple times faster ;)

PS

In that tiny tutorial, we wrote the most basic implementation of an ASCII renderer. There’s a lot we could do to improve it such as:

  • Make it more flexible allowing a user to pass additional arguments
  • Add gif animation support
  • Use xterm-256color to have colourful output

.. and more. Please let me know what you would like to see in part 2!

--

--