Pixelating images in terminal
- What this homework is about
- Prerequisites
- Here is what you will implement exactly
‼️ ‼️ Detailed requirements: read this whole section carefully**‼️ ‼️ - How your code is checked
- That's it!
The aim of this homework is to further familiarize ourselves with creating full projects in C++ using CMake, making use of everything we've learned in the last couple of lectures.
The way we are addressing this is by designing a program that is able to load an image from disk and show its pixelated version directly in the terminal.
So, after you are done, you should be able to call your program with a path to an image and see its pixelated version in the terminal:
cd homework_5/pixelator
./build/examples/pixelate ~/Pictures/grumpy_cat.png
# The image will be printed after the previous call
Original image | Pixelated image |
---|---|
![]() |
![]() |
Please make sure you've done the previous homework and that you're comfortable with all lectures since that homework until this one. The readme is a convenient way to check this.
The functionality that you will implement in this homework can be summarized in the following diagram:
graph LR
Load --> Store --> PixelateImage --> Draw
While you will implement parts of each of the above boxes, some of the functionality (Load
and Draw
) is partially handled by external libraries that you will have to use correctly.
💡 The description of this homework project is quite extensive and might be a bit confusing. It is a larger project if you are a beginner and while I tried to provide as much clarity as possible, the implementation will take a long time. You can count multiple hours (maybe even days) to implement everything described below. This is normal and figuring all of these things out is part of the fun. So give yourself time and enjoy implementing this project as much as you can.
In this part we will go over each of the block above as well as through the project structure.
The project has to be implemented in the homework_5
folder in your homework repository (that you can create from this template if you haven't done so before). If the structure of the project does not follow this guide the automatic checking won't work.
The project must follow the template described in the CMake and GoogleTest lectures.
Here is how the structure of the project folder will look like:
homework_5/
└── pixelator/
├── CMakeLists.txt
├── external
│ ├── CMakeLists.txt
│ ├── stb/ # Stb library as a submodule
│ ├── ftxui/ # Ftxui library as a submodule
│ └── googletest/ # Googletest library as a submodule
├── examples
│ ├── CMakeLists.txt
│ ├── pixelate.cpp
├── pixelator/
│ ├── test_data/
│ │ └── test.png # Test data, see below
│ ├── CMakeLists.txt # Defines all libraries
│ ├── pixelate_image.hpp
│ ├── pixelate_image.cpp
│ ├── pixelate_image_test.cpp
│ └── # rest of your files
├── .clang-format
└── readme.md # Description of your project. Go nuts!
💡 For your convenience, I am also providing an empty project skeleton that you will have to fill in with your code.
In this homework we make use of 3 external libraries:
- Googletest to test our code
- ftxui to print stuff to the terminal
- stb to load an image from disk using code in
stb_image.h
file
To add all the submodules, navigate to the homework_5/pixelator/
folder and execute the following:
git submodule add https://github.com/ArthurSonzogni/FTXUI.git external/ftxui
git submodule add https://github.com/google/googletest.git external/googletest
git submodule add https://github.com/nothings/stb.git external/stb
For your convenience, I am providing the rest of the external
folder in the empty project skeleton.
Below you will find a description of each of the above (excluding Googletests, which you should already be familiar with)
This is a library to print anything your heart desires to the terminal. It is a modern library that is easy to integrate with our code through CMake. We can use the target it generates upon build like this:
target_link_libraries(your_target PUBLIC ftxui::screen)
💡 It is a good exercise to read the readme of that project to figure out how to use it. You will need this skill a lot in your C++ journey.
We will mostly just be using the ftxui::Screen
and ftxui::Color
classes from the ftxui
library. We can use them like this:
#include "ftxui/screen/color.hpp"
#include "ftxui/screen/screen.hpp"
namespace {
const ftxui::Color kYellowishColor = ftxui::Color::RGB(255, 200, 100);
}
int main() {
const ftxui::Dimensions dimensions{ftxui::Dimension::Full()};
ftxui::Screen screen{ftxui::Screen::Create(dimensions)};
auto &pixel_left = screen.PixelAt(10, 10);
pixel_left.background_color = kYellowishColor;
pixel_left.character = ' ';
auto &pixel_right = screen.PixelAt(11, 10);
pixel_right.background_color = kYellowishColor;
pixel_right.character = ' ';
screen.Print();
return 0;
}
- We initialize the dimensions of the screen using the
ftxui
library - We create an
ftxui::Screen
from these dimensions, which takes up all the space that is available in our terminal - We take a reference to two "pixels" (actually characters in our terminal) and set them to the color we selected
- We print the
ftxui::Screen
contents to the terminal, which we should see as the output of our program
💡 Note that you can also create dimensions of custom size using
ftxui::Dimension::Fixed(int_size)
, which is useful for tests.
💡 Note that we have to set color to 2 "pixels" in order to print a square. If we only set the color to one, we will only print a rectangle instead of a square.
💡 The above example is available in the project skeleton that I provide alongside this homework. You can find this file here.
We use a single header from the stb
library, namely stb_image.h
to load our image from disk. For your convenience, this library is also set up as an external library and wrapped into a CMake target stb::stb
that can be used as follows:
target_link_libraries(your_target PUBLIC stb::stb)
The stb
library provides a couple of utilities of interest to us:
image_data = stbi_load(...)
that loads the data from the file and returns a raw pointer tounsigned char
array representing colors of the image in the following order:RGBARGBARGBA...
where,RGB
stands for values for the red, green and blue from 0 to 255, andA
represents transparencystbi_image_free(image_data)
that allows to free the memory allocated to store the image that we loaded from disk
Let's see how we can use these functions in the actual code:
// Make sure to have this in EXACTLY one cpp file
// The best place for this is the cpp file of your library
// that holds a class that wraps around the stb_image data
// For more see here: https://github.com/nothings/stb#faq
#define STB_IMAGE_IMPLEMENTATION
#include "stb/stb_image.h"
#include <filesystem>
#include <iostream>
namespace {
static constexpr auto kLoadAllChannels{0};
// A dummy color structure. Use ftxui::Color in actual code.
struct Color {
int red;
int green;
int blue;
};
} // namespace
int main(int argc, char **argv) {
if (argc < 2) { std::cerr << "No image provided.\n"; }
const std::filesystem::path image_path{argv[1]};
if (!std::filesystem::exists(image_path)) {
std::cerr << "No image provided.\n";
}
// Load the data
int rows{};
int cols{};
int channels{};
// This call also populates rows, cols, channels.
auto image_data{
stbi_load(image_path.c_str(), &cols, &rows, &channels, kLoadAllChannels)};
std::cout << "Loaded image of size: [" << rows << ", " << cols << "] with "
<< channels << " channels\n";
if (!image_data) {
std::cerr << "Failed to load image data from file: " << image_path
<< std::endl;
}
// The data is stored sequentially, in this order per pixel: red, green, blue,
// alpha This patterns repeats for every pixel of the image, so the resulting
// data layout is: [rgbargbargba...]
int query_row = 3;
int query_col = 2;
const auto index{channels * (query_row * cols + query_col)};
const Color color{
image_data[index], image_data[index + 1], image_data[index + 2]};
std::cout << "Color at pixel: [" << query_row << ", " << query_col
<< "] = RGB: (" << color.red << ", " << color.green << ", "
<< color.blue << ")\n";
// We must explicitly free the memory allocated for this image.
// The reason for this is that stb_image is a C library,
// which has no classes and no RAII in the form about which we talked before.
// Now you see why people want to write C++ and not C? ;)
stbi_image_free(image_data);
return 0;
}
The above example can be built and called as follows:
./build/examples/use_stb_image pixelator/test_data/test.png
- We get the path provided by the user. This path is stored in
argv[1]
. We convert it to thestd::filesystem::path
and check if the file exists - We load the image using the
stbi_load
function. Note here that because the function is coming from the C interface, it operates on raw memory. We will have to wrap this raw memory into something that allows us to work with it safely. This function returns us the data stored in the image in theRGBA
format where each value is anunsigned char
of value in the range from 0 to 255. It also populates the variablesrows
,cols
andchannels
the pointers to which we provide into the function call - We can now query the data that we just loaded. Because the colors are stored in a single array, we have to compute the index of the start of any particular pixel, which we do and store this value in the
index
variable. You will have to use this formula in your code too - We print the RGB value of the pixel at a given location by taking the appropriate values form the
image_data
array - We free the memory allocated for our image using the
stbi_image_free
function. Note that we will implement a memory safe wrapper around these data in this exercise
If everything goes well the program will print:
Loaded image of size: [6, 4] with 4 channels
Color at pixel: [3, 2] = RGB: (255, 255, 255)
💡 Note that this example is also provided as part of the skeleton for the project so that you can play around with it. You can find it here.
There will be 4 libraries (more concretely, library CMake targets):
stb_image_data_view
- A library that encapsulates the work with the external STB image library and makes using the data loaded from disk nicerimage
- A library that encapsulates an image that can be created for example by thePixelateImage
function from the next point in this listpixelate_image
- A library that provides the functionPixelateImage
that pixelates a providedStbImageDataView
drawer
- A library that implements a drawer - a class capable of drawing a pixelated image to the terminal
These libraries must all be defined in the pixelator/CMakeLists.txt
so that the binaries in the examples
folder and the tests could be linked against those.
Let's specify what these classes must do and, even more importantly, which interface they must follow.
This class must live in the pixelator/stb_image_data_view.hpp
header file and must be wrapped into a CMake library with the name stb_image_data_view
so that it can be linked against other targets.
The StbImageDataView
class wraps the usage of the stb
external library. It must have the following interface:
// There must be a size struct in some file, either this or some other header
struct Size {
int row;
int col;
};
// Some path to an image on disk.
const std::filesystem::path image_path{...};
// We must be able to load the image from a path and store its data
// internally in the image object.
// We should also be able to create an empty image
pixelator::StbImageDataView image{image_path};
pixelator::StbImageDataView empty_image{};
// We must have simple accessors
const pixelator::Size size = image.size();
const int rows = image.rows();
const int cols = image.cols();
// Returns true if and only if the image size is 0.
const bool empty = image.empty();
// We must be able to get a color by row and column.
// Note that we return a copy of the color here.
int row = 4;
int col = 2;
const ftxui::Color color = image.at(row, col);
// We should be able to move the images
pixelator::StbImageDataView other_image = std::move(image);
empty_image = std::move(image);
// ❌ The following code must NOT compile, copying the StbImageDataView should
// NOT be allowed
pixelator::StbImageDataView other_image = image; // ❌ Must not compile
empty_image = image; // ❌ Must not compile
// At the end, the StbImageDataView objects should free the underlying memory
// upon destruction.
💡 It is your task to figure out which data this class must store. As long as the class correctly conforms to the required interface what is stored within it is not important.
This class must live in the pixelator/image.hpp
header file and must be wrapped into a CMake library with the name image
so that it can be linked against other targets.
This class represents an image that we can modify in our program. Intuitively, it should store a vector of pixel colors and allow us to get and manipulate these colors provided their coordinate. In the following snippet we describe this interface in more detail:
// Can be created empty
pixelator::Image empty_image{};
// Can be created with size provided.
const auto rows{42};
const auto cols{23};
pixelator::Image image{rows, cols};
// Has all the useful methods to get its size.
empty_image.empty(); // Should be true.
image.empty(); // Should be false.
image.rows(); // Should be equal to rows.
image.cols(); // Should be equal to cols.
image.size(); // Should return pixelator::Size{rows, cols}.
// Should provide read access to the colors.
// All pixels must be initialized.
image.at(0, 0) == ftxui::Color{};
// Should provide write access to the colors.
const ftxui::Color yellowish{ftxui::Color::RGB(255, 200, 100)};
image.at(4, 2) = yellowish;
image.at(4, 2) == yellowish; // Should be true.
// We should be able to copy an image
const pixelator::Image image_copy{image};
image_copy.at(4, 2) == yellowish; // Should be true.
// We should be able to move an image
const pixelator::Image image_moved{std::move(image)};
image_moved.at(4, 2) == yellowish; // Should be true.
This function must be declared in the pixelator/pixelate_image.hpp
header file and must be wrapped into a CMake library with the name pixelate_image
so that it can be linked against other targets.
This is the meat of this homework. Intuitively, this function should take the original image (stored as StbImageDataView
) and the requested new size. It should then return a new pixelator::Image
object that holds the pixelated version of the original image. Here is this interface in code:
// Use the test image provided in the project skeleton for tests.
const auto kImagePath{"../../tui_pixelator/test_data/test.png"};
const pixelator::StbImageDataView image{kImagePath};
const auto pixelated_image = pixelator::PixelateImage(image, pixelator::Size{3, 2});
// Note that if the size is larger than the original image the method returns the pixelator::Image object of the same size as the input image with all the pixels having the same values as the original image.
As you can see the interface is quite simple. But the devil is in the detail. You will have to think of a way to "sample" the color when changing to a smaller size.
You will also have to handle the "aspect ratio". Meaning that the scaling factor for both rows and columns should be the same. You can choose it by picking the minimum one as follows:
// static_cast<float>(number) converts (aka "casts") a number to float.
const auto factor_cols = smaller_size.cols / static_cast<float>(image.cols());
const auto factor_rows = smaller_size.rows / static_cast<float>(image.rows());
const auto smallest_factor = std::min(factor_cols, factor_rows);
You might also make use of a function that can scale the coordinates downwards and upwards depending on the provided scale parameter:
int Scale(int number, float factor) {
return static_cast<int>(number * factor);
}
With this you should be well equipped to write this function.
Finally, there should be the pixelator::Drawer
class in the corresponding header pixelator/drawer.hpp
that allows, well, to draw the pixelator::Image
instance to the terminal using the ftxui
library (more precisely of ftxui::Screen
class) under the hood.
Here is the interface that this class must conform to:
// We should be able to create a drawer either of fixed size or of the maximum size that the terminal is capable of. Read the ftxui docs to understand these parameters better.
pixelator::Drawer full_screen_drawer{ftxui::Dimension::Full()};
pixelator::Drawer fixed_screen_drawer{ftxui::Dimension::Fixed(42)};
// All the following must be true.
// Note that the number of cols should be twice the number of rows, see explanation below the code block.
fixed_screen_drawer.rows() == 42;
fixed_screen_drawer.cols() == 84;
fixed_screen_drawer.size() == Size{42, 84};
// We should be able to set a pixelated image to our drawer.
const auto kImagePath{"../../tui_pixelator/test_data/test.png"};
const pixelator::StbImageDataView image{kImagePath};
const auto pixelated_image = pixelator::PixelateImage(image, pixelator::Size{3, 2});
full_screen_drawer.Set(pixelated_image);
// We should be able to print to the terminal by calling ftxui::Screen functions under the hood.
full_screen_drawer.Draw(); // Draws to the terminal.
full_screen_drawer.ToString(); // Prints to string, used for testing.
💡 There is one important aspect of the
Drawer
to note here. Remember the example above on the usage offtxui
and how we had to set 2 columns for 1 row in order to draw a square pixel? We have to do the same trick here. So remember to set the number of columns for the drawer as double of the provided rows.
💡 Note that
ftxui::Screen
uses X and Y to describe coordinates, while we use rows and columns. Generally,rows
correspond toY
, whilecols
correspond toX
. It is confusing, so don't worry if you don't get it right straight away.
The binary that loads an image from disk, pixelates it and outputs it to the terminal, is already provided to you in the project skeleton. You can find it here: pixelator/examples/pixelate.cpp
.
💡 Note that you will have to add the needed CMake target on your own.
All functions and classes in this project must live in the pixelator
namespace.
All of the libraries must have unit tests using the googletest
framework available as a submodule in the project's external
folder, just like it is presented in the Googletest lecture. All of the tests must live in the same folder as the file that contains the code that they test and must be registered through ctest
.
There is a number of levels of checks to satisfy the homework checker bot.
- First, your code will be built as is
- Then your tests will be called
- After that your binary will be called with the test image as input
- Finally, the bot will inject an additional test folder
validation_test
into the root of your project and run the custom-designed tests defined there
Every step above will show up as a separate line in the output from the homework checker in your PR and wiki.
Congratulations! You've implemented this relatively complex project in relatively modern C++! On to the next challenge! Do share your thoughts on the whole process in the discussions page 🙏