Build a Mini ISP Pipeline in C++17
Build a modular image processing pipeline in C++17 using CMake on WSL 2.
Introduction
30 Second Summary
Every photo on your phone passes through an invisible processing chain before it reaches your screen. Camera software companies build these Image Signal Processing pipelines to transform raw sensor data into the polished images you see every day.
In this project, you will build a mini ISP pipeline in C++17. Your pipeline processes raw pixel data through four modular stages and outputs viewable images so you can see the transformation at every step.
What You'll Build
A modular C++17 image processing pipeline with four processing stages built using abstract classes, virtual methods, and smart pointers.
By the end of this project, you'll have:
- An Image class that manages raw pixel buffers with std::vector<uint8_t> and writes PPM output files.
- An abstract PipelineStage base class with four concrete processing stages: black level correction, grayscale conversion, brightness/contrast adjustment, and thresholding.
- A Pipeline manager that chains stages using std::unique_ptr, built with CMake on WSL 2.
- Secret Mission: Add a multithreaded Gaussian blur filter using pthreads to parallelize image convolution.
Are there any prerequisites?
This project assumes basic C++ knowledge including syntax, pointers, and classes. You should also have WSL 2 already installed on your Windows machine.
Set Up Your Build Environment
Before you can write any image processing code, you need a working C++ toolchain on your system. This means a compiler that understands C++17, a build system to manage compilation, and a tool to view the images your pipeline produces.
In this step, you will install the essential build tools on WSL 2, create your project folder, and confirm everything compiles correctly with a test program.
In this step, get ready to:
- Install g++, CMake, make, and ImageMagick on WSL 2.
- Create the mini-isp/ project directory with a CMakeLists.txt build configuration.
- Verify the toolchain by building and running a test program.
Install your build tools on WSL 2
Your ISP pipeline needs four tools: a C++ compiler to turn code into an executable, a build system to orchestrate compilation, and an image viewer to inspect your pipeline's output.
- In VS Code, press F1 to open the Command Palette.
- Type WSL: Connect to WSL and select the command to connect VS Code to your WSL instance.
- Wait for VS Code to finish connecting. You will see WSL: Ubuntu (or your distribution name) in the bottom-left corner of the window.
What does connecting to WSL do?
When VS Code connects to WSL, it installs a lightweight server inside your Linux environment. Every terminal you open, every file you edit, and every extension you use now runs inside Linux rather than Windows.
This matters because g++, CMake, and the rest of your toolchain live inside WSL. Connecting first ensures VS Code can see them.
- Open a terminal by pressing Ctrl+` (backtick). The terminal should show a Linux prompt with your username and hostname.
- Install the build tools by running this command:
sudo apt update && sudo apt install -y g++ cmake make imagemagick
What does this command install?
- g++ is the GNU C++ compiler. It turns your .cpp source files into executable programs.
- cmake is a build system generator. It reads your project configuration and produces the build instructions that make follows.
- make executes the actual compilation steps that CMake generates.
- imagemagick provides the convert command, which you will use later to turn PPM image files into viewable PNGs.
- Verify the installation by running these commands:
g++ --version
cmake --version
You should see version numbers printed for both tools. For g++, expect GCC 13.x or higher on Ubuntu 24.04.
Command not found after installing?
- Make sure your terminal is a WSL terminal, not a Windows PowerShell terminal. Check the bottom-left corner of VS Code for the WSL: Ubuntu indicator.
- If sudo apt update failed, your WSL instance may not have internet access. Try closing and reopening the Ubuntu app from the Start menu, then reconnecting VS Code to WSL.
- Still stuck? Help me troubleshoot my g++ and CMake installation on WSL 2.
Create your project structure
Your ISP pipeline project needs a root folder for configuration and source files, plus a stages/ subdirectory where each image processing stage will live.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with its subdirectory and move into it by running these commands:
mkdir -p mini-isp/stages
cd mini-isp
- Confirm the directory structure by running:
ls
You should see stages listed in the output.
- Open this folder in a fresh VS Code window connected to WSL by running:
code .
What does code . do?
The code . command launches a new VS Code window that opens the current folder. Because you are running it from inside WSL, VS Code automatically connects to WSL and shows the mini-isp directory in the Explorer sidebar.
- In the new VS Code window, right-click in the Explorer sidebar (the file tree on the left).
- Click New File... and name it CMakeLists.txt.
- Paste the following code into CMakeLists.txt:
# Set the minimum CMake version required to build this project
cmake_minimum_required(VERSION 3.14)
# Name the project and declare it as a C++ project
project(MiniISP LANGUAGES CXX)
# Require the C++17 standard with no compiler-specific extensions
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Define the executable target and its source file
add_executable(mini_isp
main.cpp
)
What does this CMake configuration do?
- cmake_minimum_required(VERSION 3.14) tells CMake the oldest version that can build this project.
- CMAKE_CXX_STANDARD 17 tells the compiler to use C++17 features like std::clamp and structured bindings.
- CMAKE_CXX_STANDARD_REQUIRED ON makes C++17 a hard requirement. Without this, CMake silently falls back to an older standard if the compiler does not support C++17.
- CMAKE_CXX_EXTENSIONS OFF disables compiler-specific extensions, keeping your code portable across different compilers.
- add_executable creates an executable called mini_isp from main.cpp.
- Save CMakeLists.txt by pressing Ctrl+S.
You will not see a visual change yet. The next substep uses this file to build your first executable.
✔️ Awesome, I've got everything!
Great. Double check you have saved CMakeLists.txt before moving on.
ⓧ I'd like to double check the full code
cmake_minimum_required(VERSION 3.14)
project(MiniISP LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(mini_isp
main.cpp
)
Build and run your first executable
With CMake configured, the next step is to write a small test program and make sure the entire build pipeline works end to end.
- Right-click in the Explorer sidebar again.
- Click New File... and name it main.cpp.
- Paste the following code into main.cpp:
#include <iostream>
int main() {
// Print a greeting to confirm the build works
std::cout << "Hello, ISP!" << std::endl;
return 0;
}
What does this code do?
This is a minimal C++ program that prints Hello, ISP! to the terminal. Its only job is to confirm that your compiler, CMake configuration, and build system all work together correctly.
- Save main.cpp by pressing Ctrl+S.
- Open a terminal in VS Code by pressing Ctrl+` (backtick).
- Build the project by running these commands:
mkdir build && cd build && cmake .. && cmake --build .
What do these build commands do?
- mkdir build creates a separate folder for build artifacts, keeping your source directory clean.
- cd build moves into that folder.
- cmake .. reads your CMakeLists.txt (one directory up) and generates the build instructions.
- cmake --build . compiles the source code and produces the mini_isp executable.
- Run the executable by running:
./mini_isp
You should see Hello, ISP! printed in your terminal.
Seeing a build error?
- If CMake says it cannot find main.cpp, make sure the file is saved in the mini-isp/ root folder (not inside stages/ or build/).
- If you see a "No such file or directory" error for the build folder, your terminal may not be in the mini-isp/ directory. Run cd ~/Desktop/mini-isp and try the build commands again.
- If build/ already exists from a previous attempt, delete it first with rm -rf build and rerun the build commands.
- Still stuck? Help me fix my CMake build errors for my mini-isp project on WSL 2.
✔️ Awesome, I've got everything!
Great. Double check you have saved both CMakeLists.txt and main.cpp before moving on.
ⓧ I'd like to double check the full code
Here is your CMakeLists.txt:
cmake_minimum_required(VERSION 3.14)
project(MiniISP LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(mini_isp
main.cpp
)
Here is your main.cpp:
#include <iostream>
int main() {
std::cout << "Hello, ISP!" << std::endl;
return 0;
}
Your build environment is locked in and your first executable is running. Next up, you will build the Image class that stores and writes raw pixel data.
Build the Image Data Model
Your build environment is ready and compiling C++17 code on WSL 2. Before you can process images through a pipeline, you need a way to represent pixel data in memory.
In this step, you'll create an Image class that wraps a raw pixel buffer in a std::vector<uint8_t>. You'll add bounds-checked access, file output in the PPM format, and a test pattern generator so you can see real pixels on screen.
In this step, get ready to:
- Create an Image class with safe pixel storage and bounds-checked access.
- Add PPM file output and a test pattern factory method.
- Generate a test image, build the project, and verify the output visually.
Create the Image class with pixel storage and bounds checking
Every stage of the ISP pipeline reads pixels in and writes pixels out. You need a class that holds a flat buffer of pixel bytes along with the image dimensions, and provides safe access to individual pixel values.
- In the VS Code Explorer sidebar, right-click inside the mini-isp folder and select New File.
- Name the file Image.h and press Enter.
- Copy the following code into Image.h:
#ifndef IMAGE_H
#define IMAGE_H
#include <vector>
#include <string>
#include <cstdint>
#include <stdexcept>
#include <fstream>
class Image {
public:
// Constructor initializes pixel buffer to all zeros
Image(int width, int height, int channels)
: m_width(width), m_height(height), m_channels(channels),
m_data(width * height * channels, 0) {
if (width <= 0 || height <= 0 || channels <= 0) {
throw std::invalid_argument("Image dimensions must be positive");
}
}
// Dimension getters
int width() const { return m_width; }
int height() const { return m_height; }
int channels() const { return m_channels; }
// Direct access to the raw pixel buffer
std::vector<uint8_t>& data() { return m_data; }
const std::vector<uint8_t>& data() const { return m_data; }
// Bounds-checked access to a single pixel channel value
uint8_t& at(int x, int y, int c) {
if (x < 0 || x >= m_width || y < 0 || y >= m_height || c < 0 || c >= m_channels) {
throw std::out_of_range("Pixel access out of bounds");
}
return m_data[(y * m_width + x) * m_channels + c];
}
const uint8_t& at(int x, int y, int c) const {
if (x < 0 || x >= m_width || y < 0 || y >= m_height || c < 0 || c >= m_channels) {
throw std::out_of_range("Pixel access out of bounds");
}
return m_data[(y * m_width + x) * m_channels + c];
}
private:
int m_width;
int m_height;
int m_channels;
std::vector<uint8_t> m_data;
};
#endif
What does this code do?
The Image class stores every pixel in a flat std::vector<uint8_t> buffer. Each byte represents one channel value (0 to 255) for a single pixel.
- The constructor takes width, height, and channels, pre-allocating the buffer to width * height * channels bytes. It throws std::invalid_argument if any dimension is zero or negative.
- The at(x, y, c) method provides bounds-checked access to a single pixel channel. It calculates the position in the flat buffer using (y * m_width + x) * m_channels + c.
- Both const and non-const overloads of at() exist so the pipeline can read pixels from a const Image& input and write pixels into a mutable output.
- Save Image.h.
You won't see any output yet. The next substep adds the methods that write images to disk and generate test data.
Red squiggles in VS Code?
If IntelliSense highlights errors in Image.h, make sure the file is saved at the root of the mini-isp/ folder, not inside the stages/ subdirectory or the build/ folder.
Also check that the #ifndef IMAGE_H header guard at the top and #endif at the bottom are both present. Missing either one causes confusing errors.
help me fix IntelliSense errors in my Image.h file
Add PPM output and test pattern generation
The Image class can store pixels, but it can't do anything visible with them yet. You need a method to write images to disk and a factory method to create test data.
- In Image.h, find the closing brace of the second at() method (the const overload). Copy the following code below it, before the private: line:
// Write image data to a PPM file (P6 binary format)
void writePPM(const std::string& filename) const {
std::ofstream file(filename, std::ios::binary);
if (!file.is_open()) {
throw std::runtime_error("Cannot open file: " + filename);
}
file << "P6\n" << m_width << " " << m_height << "\n255\n";
if (m_channels == 3) {
// Write RGB data directly
file.write(reinterpret_cast<const char*>(m_data.data()), m_data.size());
} else if (m_channels == 1) {
// Convert grayscale to RGB by tripling each pixel value
for (size_t i = 0; i < m_data.size(); ++i) {
char val = static_cast<char>(m_data[i]);
file.write(&val, 1);
file.write(&val, 1);
file.write(&val, 1);
}
} else {
throw std::runtime_error("PPM output requires 1 or 3 channels");
}
}
// Generate a gradient test pattern as a 3-channel RGB image
static Image generateTestPattern(int width, int height) {
Image img(width, height, 3);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
// Red increases left to right, green increases top to bottom
img.at(x, y, 0) = static_cast<uint8_t>((x * 255) / width);
img.at(x, y, 1) = static_cast<uint8_t>((y * 255) / height);
img.at(x, y, 2) = static_cast<uint8_t>(128);
}
}
return img;
}
What does this code do?
- writePPM() outputs the image in the PPM P6 binary format. The header is plain text (P6, dimensions, max value), followed by raw RGB bytes. No external image library needed.
- For 3-channel images, the raw bytes are written directly. For 1-channel grayscale, each pixel value is written three times (R=G=B) so the PPM file stays valid RGB.
- generateTestPattern() creates a 256x256 gradient where the red channel increases left to right, the green channel increases top to bottom, and the blue channel stays at 128. This gives you a distinctive colour ramp to verify the pipeline is working.
- Save Image.h.
You won't see a change yet. The next substep uses these methods to produce a real image file.
Seeing a syntax error after pasting?
Make sure the new methods sit between the closing brace of the second at() method and the private: line. Pasting them outside the class body (after the final };) causes "undeclared identifier" errors.
help me fix syntax errors in Image.h after adding writePPM and generateTestPattern
✔️ Awesome, I've got everything!
Great. Double check you've saved Image.h before moving on.
ⓧ I'd like to double check the full code
#ifndef IMAGE_H
#define IMAGE_H
#include <vector>
#include <string>
#include <cstdint>
#include <stdexcept>
#include <fstream>
class Image {
public:
Image(int width, int height, int channels)
: m_width(width), m_height(height), m_channels(channels),
m_data(width * height * channels, 0) {
if (width <= 0 || height <= 0 || channels <= 0) {
throw std::invalid_argument("Image dimensions must be positive");
}
}
int width() const { return m_width; }
int height() const { return m_height; }
int channels() const { return m_channels; }
std::vector<uint8_t>& data() { return m_data; }
const std::vector<uint8_t>& data() const { return m_data; }
uint8_t& at(int x, int y, int c) {
if (x < 0 || x >= m_width || y < 0 || y >= m_height || c < 0 || c >= m_channels) {
throw std::out_of_range("Pixel access out of bounds");
}
return m_data[(y * m_width + x) * m_channels + c];
}
const uint8_t& at(int x, int y, int c) const {
if (x < 0 || x >= m_width || y < 0 || y >= m_height || c < 0 || c >= m_channels) {
throw std::out_of_range("Pixel access out of bounds");
}
return m_data[(y * m_width + x) * m_channels + c];
}
void writePPM(const std::string& filename) const {
std::ofstream file(filename, std::ios::binary);
if (!file.is_open()) {
throw std::runtime_error("Cannot open file: " + filename);
}
file << "P6\n" << m_width << " " << m_height << "\n255\n";
if (m_channels == 3) {
file.write(reinterpret_cast<const char*>(m_data.data()), m_data.size());
} else if (m_channels == 1) {
for (size_t i = 0; i < m_data.size(); ++i) {
char val = static_cast<char>(m_data[i]);
file.write(&val, 1);
file.write(&val, 1);
file.write(&val, 1);
}
} else {
throw std::runtime_error("PPM output requires 1 or 3 channels");
}
}
static Image generateTestPattern(int width, int height) {
Image img(width, height, 3);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
img.at(x, y, 0) = static_cast<uint8_t>((x * 255) / width);
img.at(x, y, 1) = static_cast<uint8_t>((y * 255) / height);
img.at(x, y, 2) = static_cast<uint8_t>(128);
}
}
return img;
}
private:
int m_width;
int m_height;
int m_channels;
std::vector<uint8_t> m_data;
};
#endif
Generate and verify a test image
Your Image class is complete. Time to put it to work by updating main.cpp to generate a test pattern, write it to disk, and verify the result.
- Open main.cpp in VS Code and replace the entire contents with the following code:
#include "Image.h"
#include <iostream>
int main() {
try {
// Create a 256x256 test gradient image
Image testImage = Image::generateTestPattern(256, 256);
testImage.writePPM("output_00_original.ppm");
std::cout << "Generated test pattern: 256x256 RGB" << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
What does this code do?
- Image::generateTestPattern(256, 256) calls the static factory method to create a 256x256 RGB gradient in memory.
- writePPM("output_00_original.ppm") saves that image to a PPM file so you can view it.
- The try/catch block catches any exceptions (like invalid dimensions or file I/O failures) and prints the error message instead of crashing silently.
- Save main.cpp.
- Rebuild the project from your build/ directory by running these commands:
cd build && cmake --build .
- Run the pipeline by running:
./mini_isp
You should see Generated test pattern: 256x256 RGB printed in the terminal.
- Convert the PPM file to a PNG so you can open it in a regular image viewer by running:
convert output_00_original.ppm output_00_original.png
What is PPM format?
PPM (Portable Pixel Map) is the simplest image format for raw pixel data. The P6 binary variant has a plain-text header followed by raw RGB bytes. It requires no external library to write, which is why ISP projects use it as an intermediate output format.
The convert command is part of ImageMagick. It translates PPM to PNG so you can view the image in any viewer or browser.
- Open output_00_original.png in the VS Code Explorer sidebar by clicking on it, or open it in your Windows file browser from the WSL path.
You should see a colour gradient. Red increases from left to right, green increases from top to bottom, and the overall tone is slightly blue from the constant 128 blue channel.
Build failed or no output file?
- If you see a "file not found" error for Image.h, confirm that Image.h is in the mini-isp/ root folder (same directory as main.cpp and CMakeLists.txt).
- If the build succeeds but ./mini_isp prints an error, check the error message. A "Cannot open file" error means the program can't write to the current directory.
- If convert fails, confirm ImageMagick is installed by running convert --version. If it's missing, re-run the install command from Step 1.
help me troubleshoot my mini ISP build or image output errors
✔️ Awesome, I've got everything!
Great. Make sure both Image.h and main.cpp are saved before moving on.
ⓧ I'd like to double check the full code
#ifndef IMAGE_H
#define IMAGE_H
#include <vector>
#include <string>
#include <cstdint>
#include <stdexcept>
#include <fstream>
class Image {
public:
Image(int width, int height, int channels)
: m_width(width), m_height(height), m_channels(channels),
m_data(width * height * channels, 0) {
if (width <= 0 || height <= 0 || channels <= 0) {
throw std::invalid_argument("Image dimensions must be positive");
}
}
int width() const { return m_width; }
int height() const { return m_height; }
int channels() const { return m_channels; }
std::vector<uint8_t>& data() { return m_data; }
const std::vector<uint8_t>& data() const { return m_data; }
uint8_t& at(int x, int y, int c) {
if (x < 0 || x >= m_width || y < 0 || y >= m_height || c < 0 || c >= m_channels) {
throw std::out_of_range("Pixel access out of bounds");
}
return m_data[(y * m_width + x) * m_channels + c];
}
const uint8_t& at(int x, int y, int c) const {
if (x < 0 || x >= m_width || y < 0 || y >= m_height || c < 0 || c >= m_channels) {
throw std::out_of_range("Pixel access out of bounds");
}
return m_data[(y * m_width + x) * m_channels + c];
}
void writePPM(const std::string& filename) const {
std::ofstream file(filename, std::ios::binary);
if (!file.is_open()) {
throw std::runtime_error("Cannot open file: " + filename);
}
file << "P6\n" << m_width << " " << m_height << "\n255\n";
if (m_channels == 3) {
file.write(reinterpret_cast<const char*>(m_data.data()), m_data.size());
} else if (m_channels == 1) {
for (size_t i = 0; i < m_data.size(); ++i) {
char val = static_cast<char>(m_data[i]);
file.write(&val, 1);
file.write(&val, 1);
file.write(&val, 1);
}
} else {
throw std::runtime_error("PPM output requires 1 or 3 channels");
}
}
static Image generateTestPattern(int width, int height) {
Image img(width, height, 3);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
img.at(x, y, 0) = static_cast<uint8_t>((x * 255) / width);
img.at(x, y, 1) = static_cast<uint8_t>((y * 255) / height);
img.at(x, y, 2) = static_cast<uint8_t>(128);
}
}
return img;
}
private:
int m_width;
int m_height;
int m_channels;
std::vector<uint8_t> m_data;
};
#endif
#include "Image.h"
#include <iostream>
int main() {
try {
Image testImage = Image::generateTestPattern(256, 256);
testImage.writePPM("output_00_original.ppm");
std::cout << "Generated test pattern: 256x256 RGB" << std::endl;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
Your data model is working and producing real images. Next up, you'll create the abstract pipeline stage and four concrete processing stages that transform these images step by step.
Create the Processing Stages
In the previous step, you built the Image class to store pixel data and write PPM files. You now have a working data model, but no way to transform those pixels.
In a real ISP pipeline, raw image data flows through a chain of processing stages. Each stage performs one specific transformation: correcting sensor noise, converting colors, adjusting brightness, or sharpening edges. In this step, you'll define an abstract interface that all stages must follow, then build four concrete stages that each manipulate pixel data in a different way.
In this step, get ready to:
- Create an abstract PipelineStage base class with pure virtual methods.
- Build black level correction and grayscale conversion stages.
- Build brightness/contrast and threshold stages.
Create the abstract base class
Every processing stage in the pipeline needs to follow the same interface. An abstract class defines this contract: any class that inherits from it must implement a process method and a name method.
- Create a new file called PipelineStage.h in your mini-isp folder (the same level as Image.h) and paste this code:
#ifndef PIPELINE_STAGE_H
#define PIPELINE_STAGE_H
#include "Image.h"
#include <string>
class PipelineStage {
public:
// Virtual destructor for safe cleanup through base pointers
virtual ~PipelineStage() = default;
// Every stage must implement process and name
virtual Image process(const Image& input) = 0;
virtual std::string name() const = 0;
};
#endif
What does this code do?
- virtual ~PipelineStage() = default; gives the base class a virtual destructor. This ensures proper cleanup when stages are deleted through a base class pointer.
- = 0 at the end of both methods makes them pure virtual. Any class inheriting from PipelineStage must implement both process and name, or the compiler will throw an error.
- Save PipelineStage.h.
- Confirm the file exists by running:
ls *.h
- You should see Image.h and PipelineStage.h in the output.
Don't see PipelineStage.h?
- Make sure you created the file in the mini-isp folder, not inside build or another subdirectory.
- Check that the filename matches exactly: PipelineStage.h (capital P and capital S).
- Why isn't my PipelineStage.h showing up?
✔️ Awesome, I've got everything!
Great. Make sure you've saved PipelineStage.h before moving on.
ⓧ I'd like to double check the full code
#ifndef PIPELINE_STAGE_H
#define PIPELINE_STAGE_H
#include "Image.h"
#include <string>
class PipelineStage {
public:
virtual ~PipelineStage() = default;
virtual Image process(const Image& input) = 0;
virtual std::string name() const = 0;
};
#endif
Why use an abstract class here?
The abstract class lets the pipeline treat every stage identically, regardless of what it does internally. The pipeline just calls process() on each stage without knowing whether it's correcting black levels or converting to grayscale.
This pattern is called polymorphism. Production ISP frameworks use exactly this design to keep stages modular and interchangeable.
Build the black level and grayscale stages
These first two stages handle foundational corrections that happen early in any ISP pipeline. Black level correction removes the sensor's baseline noise floor. Grayscale conversion reduces a color image to a single luminance channel.
- Create the stages subdirectory inside your mini-isp folder by running:
mkdir stages
- Confirm the directory was created by running:
ls
- You should see a stages directory alongside your other files.
Don't see the stages directory?
- Make sure your terminal is in the mini-isp folder. Run pwd to check your current location.
- Why can't I create the stages directory?
Now you'll create the first processing stage. Camera sensors have a non-zero baseline signal even in complete darkness. Black level correction subtracts that baseline and rescales the remaining range.
- Create a new file called BlackLevelCorrection.h inside the stages folder and paste this code:
#ifndef BLACK_LEVEL_CORRECTION_H
#define BLACK_LEVEL_CORRECTION_H
#include "PipelineStage.h"
#include <algorithm>
class BlackLevelCorrection : public PipelineStage {
public:
// Constructor validates that black level is within 0-255
explicit BlackLevelCorrection(int blackLevel)
: m_blackLevel(blackLevel) {
if (blackLevel < 0 || blackLevel > 255) {
throw std::invalid_argument("Black level must be between 0 and 255");
}
}
Image process(const Image& input) override {
Image output(input.width(), input.height(), input.channels());
const auto& inData = input.data();
auto& outData = output.data();
// Subtract black level offset and rescale to full 0-255 range
for (size_t i = 0; i < inData.size(); ++i) {
int corrected = static_cast<int>(inData[i]) - m_blackLevel;
corrected = (corrected * 255) / (255 - m_blackLevel);
outData[i] = static_cast<uint8_t>(std::clamp(corrected, 0, 255));
}
return output;
}
std::string name() const override { return "BlackLevelCorrection"; }
private:
int m_blackLevel;
};
#endif
What does this stage do?
- The constructor stores the black level value in m_blackLevel and validates it's between 0 and 255.
- The process method subtracts the black level from every pixel, then rescales the result so the remaining values stretch across the full 0-255 range.
- std::clamp (a C++17 feature) prevents negative values from wrapping around to large numbers.
- Save stages/BlackLevelCorrection.h.
- Confirm the file exists by running:
ls stages/
- You should see BlackLevelCorrection.h in the output.
Don't see BlackLevelCorrection.h in the stages folder?
- Make sure you created the file inside stages/, not in the mini-isp root folder.
- Check that the include guard matches: #ifndef BLACK_LEVEL_CORRECTION_H. A mistyped guard can cause silent compile failures later.
- Why isn't my BlackLevelCorrection stage working?
✔️ Awesome, I've got everything!
Great. Make sure you've saved stages/BlackLevelCorrection.h before moving on.
ⓧ I'd like to double check the full code
#ifndef BLACK_LEVEL_CORRECTION_H
#define BLACK_LEVEL_CORRECTION_H
#include "PipelineStage.h"
#include <algorithm>
class BlackLevelCorrection : public PipelineStage {
public:
explicit BlackLevelCorrection(int blackLevel)
: m_blackLevel(blackLevel) {
if (blackLevel < 0 || blackLevel > 255) {
throw std::invalid_argument("Black level must be between 0 and 255");
}
}
Image process(const Image& input) override {
Image output(input.width(), input.height(), input.channels());
const auto& inData = input.data();
auto& outData = output.data();
for (size_t i = 0; i < inData.size(); ++i) {
int corrected = static_cast<int>(inData[i]) - m_blackLevel;
corrected = (corrected * 255) / (255 - m_blackLevel);
outData[i] = static_cast<uint8_t>(std::clamp(corrected, 0, 255));
}
return output;
}
std::string name() const override { return "BlackLevelCorrection"; }
private:
int m_blackLevel;
};
#endif
The second stage converts color images to grayscale. This uses a weighted average of the RGB channels based on how the human eye perceives brightness.
- Create a new file called GrayscaleConversion.h inside the stages folder and paste this code:
#ifndef GRAYSCALE_CONVERSION_H
#define GRAYSCALE_CONVERSION_H
#include "PipelineStage.h"
class GrayscaleConversion : public PipelineStage {
public:
Image process(const Image& input) override {
// Only works on 3-channel RGB images
if (input.channels() != 3) {
throw std::runtime_error("GrayscaleConversion requires 3-channel input");
}
Image output(input.width(), input.height(), 1);
const auto& inData = input.data();
auto& outData = output.data();
// Convert RGB to grayscale using ITU-R BT.601 luminance formula
for (int y = 0; y < input.height(); ++y) {
for (int x = 0; x < input.width(); ++x) {
int idx = (y * input.width() + x) * 3;
uint8_t gray = static_cast<uint8_t>(
0.299 * inData[idx] +
0.587 * inData[idx + 1] +
0.114 * inData[idx + 2]
);
outData[y * input.width() + x] = gray;
}
}
return output;
}
std::string name() const override { return "GrayscaleConversion"; }
};
#endif
What does this stage do?
- This stage converts a 3-channel RGB image to a 1-channel grayscale image using the ITU-R BT.601 luminance formula: Y = 0.299R + 0.587G + 0.114B.
- The weights reflect how the human eye perceives brightness. Green contributes the most (0.587) because our eyes are most sensitive to green light.
- The throw statement at the top ensures this stage only runs on 3-channel images, catching pipeline configuration errors early.
- Save stages/GrayscaleConversion.h.
- Run ls stages/ again. You should see both BlackLevelCorrection.h and GrayscaleConversion.h.
Don't see GrayscaleConversion.h?
- Double-check that the file is inside the stages folder. If you accidentally created it in the project root, move it with mv GrayscaleConversion.h stages/.
- Why isn't my GrayscaleConversion stage showing up?
✔️ Awesome, I've got everything!
Great. Make sure you've saved stages/GrayscaleConversion.h before moving on.
ⓧ I'd like to double check the full code
#ifndef GRAYSCALE_CONVERSION_H
#define GRAYSCALE_CONVERSION_H
#include "PipelineStage.h"
class GrayscaleConversion : public PipelineStage {
public:
Image process(const Image& input) override {
if (input.channels() != 3) {
throw std::runtime_error("GrayscaleConversion requires 3-channel input");
}
Image output(input.width(), input.height(), 1);
const auto& inData = input.data();
auto& outData = output.data();
for (int y = 0; y < input.height(); ++y) {
for (int x = 0; x < input.width(); ++x) {
int idx = (y * input.width() + x) * 3;
uint8_t gray = static_cast<uint8_t>(
0.299 * inData[idx] +
0.587 * inData[idx + 1] +
0.114 * inData[idx + 2]
);
outData[y * input.width() + x] = gray;
}
}
return output;
}
std::string name() const override { return "GrayscaleConversion"; }
};
#endif
Build the brightness and threshold stages
The final two stages handle output-level adjustments. Brightness and contrast control how light or punchy the image looks. Thresholding converts each pixel to pure black or white, which is useful for simple segmentation tasks.
- Create a new file called BrightnessContrast.h inside the stages folder and paste this code:
#ifndef BRIGHTNESS_CONTRAST_H
#define BRIGHTNESS_CONTRAST_H
#include "PipelineStage.h"
#include <algorithm>
class BrightnessContrast : public PipelineStage {
public:
// Takes a brightness offset and a contrast multiplier
BrightnessContrast(int brightness, float contrast)
: m_brightness(brightness), m_contrast(contrast) {
if (contrast < 0.0f) {
throw std::invalid_argument("Contrast factor must be non-negative");
}
}
Image process(const Image& input) override {
Image output(input.width(), input.height(), input.channels());
const auto& inData = input.data();
auto& outData = output.data();
// Apply contrast around midpoint 128, then add brightness offset
for (size_t i = 0; i < inData.size(); ++i) {
float pixel = static_cast<float>(inData[i]);
pixel = m_contrast * (pixel - 128.0f) + 128.0f + m_brightness;
outData[i] = static_cast<uint8_t>(std::clamp(static_cast<int>(pixel), 0, 255));
}
return output;
}
std::string name() const override { return "BrightnessContrast"; }
private:
int m_brightness;
float m_contrast;
};
#endif
What does this stage do?
- The contrast multiplier (m_contrast) scales pixel values around the midpoint of 128. A value above 1.0 increases contrast. A value below 1.0 decreases it.
- The brightness offset (m_brightness) shifts all pixel values up or down after contrast is applied.
- The formula m_contrast * (pixel - 128) + 128 + m_brightness centers the contrast adjustment, then applies the brightness shift. The result is clamped to the 0-255 range.
- Save stages/BrightnessContrast.h.
- Run ls stages/ to confirm. You should see three files: BlackLevelCorrection.h, BrightnessContrast.h, and GrayscaleConversion.h.
Don't see BrightnessContrast.h?
- Verify the file is in the stages folder, not the project root. The path should be stages/BrightnessContrast.h.
- Why isn't my BrightnessContrast stage file appearing?
✔️ Awesome, I've got everything!
Great. Make sure you've saved stages/BrightnessContrast.h before moving on.
ⓧ I'd like to double check the full code
#ifndef BRIGHTNESS_CONTRAST_H
#define BRIGHTNESS_CONTRAST_H
#include "PipelineStage.h"
#include <algorithm>
class BrightnessContrast : public PipelineStage {
public:
BrightnessContrast(int brightness, float contrast)
: m_brightness(brightness), m_contrast(contrast) {
if (contrast < 0.0f) {
throw std::invalid_argument("Contrast factor must be non-negative");
}
}
Image process(const Image& input) override {
Image output(input.width(), input.height(), input.channels());
const auto& inData = input.data();
auto& outData = output.data();
for (size_t i = 0; i < inData.size(); ++i) {
float pixel = static_cast<float>(inData[i]);
pixel = m_contrast * (pixel - 128.0f) + 128.0f + m_brightness;
outData[i] = static_cast<uint8_t>(std::clamp(static_cast<int>(pixel), 0, 255));
}
return output;
}
std::string name() const override { return "BrightnessContrast"; }
private:
int m_brightness;
float m_contrast;
};
#endif
The last stage is the simplest. It converts every pixel to either pure black or pure white based on a threshold value.
- Create a new file called Threshold.h inside the stages folder and paste this code:
#ifndef THRESHOLD_H
#define THRESHOLD_H
#include "PipelineStage.h"
class Threshold : public PipelineStage {
public:
// Store the threshold cutoff value
explicit Threshold(uint8_t threshold)
: m_threshold(threshold) {}
Image process(const Image& input) override {
Image output(input.width(), input.height(), input.channels());
const auto& inData = input.data();
auto& outData = output.data();
// Convert each pixel to 0 (black) or 255 (white)
for (size_t i = 0; i < inData.size(); ++i) {
outData[i] = (inData[i] >= m_threshold) ? 255 : 0;
}
return output;
}
std::string name() const override { return "Threshold"; }
private:
uint8_t m_threshold;
};
#endif
What does this stage do?
- Every pixel at or above the threshold becomes 255 (white). Every pixel below becomes 0 (black).
- This produces a binary image useful for edge detection, simple segmentation, or document scanning in real ISP pipelines.
- Save stages/Threshold.h.
- Confirm all four stage files exist by running:
ls stages/
- You should see BlackLevelCorrection.h, BrightnessContrast.h, GrayscaleConversion.h, and Threshold.h in the output.
Missing one or more stage files?
- Make sure all four files are inside the stages folder, not the project root. Run ls *.h in the project root to check if any ended up in the wrong place.
- Check that each filename matches exactly (including capitalization). For example, Threshold.h not threshold.h.
- I'm missing some of my stage files. Help me figure out what happened.
✔️ Awesome, I've got everything!
Great. Make sure all four files inside stages/ are saved.
ⓧ I'd like to double check the full code
#ifndef THRESHOLD_H
#define THRESHOLD_H
#include "PipelineStage.h"
class Threshold : public PipelineStage {
public:
explicit Threshold(uint8_t threshold)
: m_threshold(threshold) {}
Image process(const Image& input) override {
Image output(input.width(), input.height(), input.channels());
const auto& inData = input.data();
auto& outData = output.data();
for (size_t i = 0; i < inData.size(); ++i) {
outData[i] = (inData[i] >= m_threshold) ? 255 : 0;
}
return output;
}
std::string name() const override { return "Threshold"; }
private:
uint8_t m_threshold;
};
#endif
All four processing stages are ready to go. In the next step, you'll wire them together into a pipeline that chains each transformation in sequence and produces viewable images at every stage.
Build and Run the Pipeline
You have an Image class that holds pixel data and four processing stages that each transform it in a different way. The missing piece is a manager that chains these stages together, passes each stage's output to the next, and saves the intermediate results so you can see what happened at every step.
In this step, you will create a Pipeline class that owns its stages through smart pointers, wire it up in main.cpp, and run the full four-stage pipeline to produce viewable images at every processing stage.
In this step, get ready to:
- Create a Pipeline manager class that chains stages using std::unique_ptr.
- Update main.cpp to build and execute a four-stage ISP pipeline.
- Rebuild, run, and convert the output images to PNG for visual inspection.
Create the Pipeline manager class
The Pipeline class is the orchestrator. It stores stages in a vector of std::unique_ptr, meaning the Pipeline exclusively owns each stage. When you add a stage, ownership transfers via std::move.
- Create a new file called Pipeline.h in the mini-isp folder (the same level as Image.h and PipelineStage.h).
- Copy the following code into Pipeline.h:
#ifndef PIPELINE_H
#define PIPELINE_H
#include "PipelineStage.h"
#include <vector>
#include <memory>
#include <string>
#include <iostream>
class Pipeline {
public:
// Takes ownership of a stage via std::move
void addStage(std::unique_ptr<PipelineStage> stage) {
m_stages.push_back(std::move(stage));
}
// Runs each stage in sequence, saving a PPM after each one
Image execute(const Image& input) const {
if (m_stages.empty()) {
throw std::runtime_error("Pipeline has no stages");
}
Image current = input;
int stageNum = 1;
for (const auto& stage : m_stages) {
std::cout << "Running stage " << stageNum << ": "
<< stage->name() << std::endl;
current = stage->process(current);
// Build a filename like output_01_BlackLevelCorrection.ppm
std::string filename = "output_0" + std::to_string(stageNum)
+ "_" + stage->name() + ".ppm";
current.writePPM(filename);
std::cout << " Output: " << current.width() << "x"
<< current.height() << " ("
<< current.channels() << " ch) -> "
<< filename << std::endl;
++stageNum;
}
return current;
}
// Returns the number of registered stages
size_t stageCount() const { return m_stages.size(); }
private:
// The pipeline exclusively owns its stages
std::vector<std::unique_ptr<PipelineStage>> m_stages;
};
#endif
What does this code do?
- m_stages is a std::vector of std::unique_ptr<PipelineStage>. Each unique_ptr guarantees the Pipeline is the sole owner of that stage. When the Pipeline is destroyed, every stage is automatically cleaned up.
- addStage accepts a unique_ptr by value and moves it into the vector. After std::move, the caller no longer holds the pointer.
- execute loops through every stage, feeding the output of one stage as the input to the next. After each stage, it writes a PPM file and logs the dimensions so you can trace the pipeline's progress.
- Save Pipeline.h.
You won't see a change yet. The next chunk wires this class into main.cpp.
✔️ Awesome, I've got everything!
Great. Double check you've saved Pipeline.h.
ⓧ I'd like to double check the full code
#ifndef PIPELINE_H
#define PIPELINE_H
#include "PipelineStage.h"
#include <vector>
#include <memory>
#include <string>
#include <iostream>
class Pipeline {
public:
void addStage(std::unique_ptr<PipelineStage> stage) {
m_stages.push_back(std::move(stage));
}
Image execute(const Image& input) const {
if (m_stages.empty()) {
throw std::runtime_error("Pipeline has no stages");
}
Image current = input;
int stageNum = 1;
for (const auto& stage : m_stages) {
std::cout << "Running stage " << stageNum << ": "
<< stage->name() << std::endl;
current = stage->process(current);
std::string filename = "output_0" + std::to_string(stageNum)
+ "_" + stage->name() + ".ppm";
current.writePPM(filename);
std::cout << " Output: " << current.width() << "x"
<< current.height() << " ("
<< current.channels() << " ch) -> "
<< filename << std::endl;
++stageNum;
}
return current;
}
size_t stageCount() const { return m_stages.size(); }
private:
std::vector<std::unique_ptr<PipelineStage>> m_stages;
};
#endif
Wire up the pipeline in main.cpp
Now you need to bring the Pipeline and all four stages together in main.cpp. Each stage is created with std::make_unique and added to the pipeline, which takes exclusive ownership.
- Open main.cpp and replace its entire contents with the following code:
#include "Image.h"
#include "Pipeline.h"
#include "stages/BlackLevelCorrection.h"
#include "stages/GrayscaleConversion.h"
#include "stages/BrightnessContrast.h"
#include "stages/Threshold.h"
#include <iostream>
#include <memory>
int main() {
try {
std::cout << "=== Mini ISP Pipeline ===" << std::endl;
// Generate a 256x256 RGB gradient as our test input
Image testImage = Image::generateTestPattern(256, 256);
testImage.writePPM("output_00_original.ppm");
std::cout << "Generated test pattern: 256x256 RGB" << std::endl;
// Build the pipeline with four processing stages
Pipeline pipeline;
pipeline.addStage(std::make_unique<BlackLevelCorrection>(16));
pipeline.addStage(std::make_unique<GrayscaleConversion>());
pipeline.addStage(std::make_unique<BrightnessContrast>(10, 1.2f));
pipeline.addStage(std::make_unique<Threshold>(128));
std::cout << "\nPipeline configured with "
<< pipeline.stageCount() << " stages\n" << std::endl;
// Execute all stages in sequence
Image result = pipeline.execute(testImage);
std::cout << "\nPipeline complete! Check output_*.ppm files"
<< std::endl;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
What does this code do?
- The new includes bring in Pipeline.h, all four stage headers, and <memory> for std::make_unique.
- std::make_unique<BlackLevelCorrection>(16) creates a stage on the heap and wraps it in a unique_ptr. The addStage call then moves ownership into the pipeline.
- The four stages run in order: black level correction (offset 16), grayscale conversion, brightness/contrast (brightness 10, contrast 1.2), and thresholding (threshold 128).
- The try/catch block catches any std::exception thrown by the stages or the pipeline and prints the error message before exiting.
- Save main.cpp.
You won't see a change yet. The next chunk updates CMakeLists.txt and rebuilds the project.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.cpp.
ⓧ I'd like to double check the full code
#include "Image.h"
#include "Pipeline.h"
#include "stages/BlackLevelCorrection.h"
#include "stages/GrayscaleConversion.h"
#include "stages/BrightnessContrast.h"
#include "stages/Threshold.h"
#include <iostream>
#include <memory>
int main() {
try {
std::cout << "=== Mini ISP Pipeline ===" << std::endl;
Image testImage = Image::generateTestPattern(256, 256);
testImage.writePPM("output_00_original.ppm");
std::cout << "Generated test pattern: 256x256 RGB" << std::endl;
Pipeline pipeline;
pipeline.addStage(std::make_unique<BlackLevelCorrection>(16));
pipeline.addStage(std::make_unique<GrayscaleConversion>());
pipeline.addStage(std::make_unique<BrightnessContrast>(10, 1.2f));
pipeline.addStage(std::make_unique<Threshold>(128));
std::cout << "\nPipeline configured with "
<< pipeline.stageCount() << " stages\n" << std::endl;
Image result = pipeline.execute(testImage);
std::cout << "\nPipeline complete! Check output_*.ppm files"
<< std::endl;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
Update the build configuration and run the pipeline
Your stage headers live in the stages/ subdirectory, but CMake does not automatically know where to find header files. You need to tell it to look in the project root so that #include "stages/BlackLevelCorrection.h" resolves correctly.
- Open CMakeLists.txt in the mini-isp folder.
- Find the closing ) of the add_executable block. It currently looks like this:
add_executable(mini_isp
main.cpp
)
- Add this line directly after the closing ) so the section now looks like this:
add_executable(mini_isp
main.cpp
)
# Tell CMake to search the project root for header files
target_include_directories(mini_isp PRIVATE ${CMAKE_SOURCE_DIR})
Why do we need target_include_directories?
Without this line, the compiler would not find headers like stages/BlackLevelCorrection.h because it only searches its default system paths. target_include_directories adds the project root (${CMAKE_SOURCE_DIR}) as a search directory for the mini_isp target.
- Save CMakeLists.txt.
✔️ Awesome, I've got everything!
Great. Double check you've saved CMakeLists.txt.
ⓧ I'd like to double check the full code
cmake_minimum_required(VERSION 3.14)
project(MiniISP LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(mini_isp
main.cpp
)
target_include_directories(mini_isp PRIVATE ${CMAKE_SOURCE_DIR})
- Navigate to your build directory and rebuild the project by running these commands:
cd ~/Desktop/mini-isp/build
cmake ..
cmake --build .
What do these commands do?
- cmake .. re-runs the configure step so CMake picks up the new target_include_directories line.
- cmake --build . compiles the updated main.cpp and links the executable.
- You should see the build complete with no errors.
Seeing a build error?
- If you see a "file not found" error for any of the stage headers, make sure your CMakeLists.txt includes the target_include_directories line and that you re-ran cmake .. before building.
- If you see a "no matching function" or "undeclared identifier" error, double check that your #include lines in main.cpp match the filenames exactly, including capitalization.
- If you see Pipeline.h not found, confirm the file is in the mini-isp root folder, not inside stages/.
Why won't my project build after adding the pipeline?
- Run the pipeline by executing this command:
./mini_isp
You should see output like this in your terminal:
=== Mini ISP Pipeline ===
Generated test pattern: 256x256 RGB
Pipeline configured with 4 stages
Running stage 1: BlackLevelCorrection
Output: 256x256 (3 ch) -> output_01_BlackLevelCorrection.ppm
Running stage 2: GrayscaleConversion
Output: 256x256 (1 ch) -> output_02_GrayscaleConversion.ppm
Running stage 3: BrightnessContrast
Output: 256x256 (1 ch) -> output_03_BrightnessContrast.ppm
Running stage 4: Threshold
Output: 256x256 (1 ch) -> output_04_Threshold.ppm
Pipeline complete! Check output_*.ppm files
What should I see?
The output confirms each stage ran in sequence. Notice how the first stage outputs 3 channels (RGB), but after GrayscaleConversion the channel count drops to 1. Every subsequent stage operates on that single-channel grayscale image.
- Convert all output PPM files to PNG so you can view them by running this command:
for f in output_*.ppm; do convert "$f" "${f%.ppm}.png"; done
- Confirm the PNG files were created by running:
ls output_*.png
You should see five PNG files: output_00_original.png, output_01_BlackLevelCorrection.png, output_02_GrayscaleConversion.png, output_03_BrightnessContrast.png, and output_04_Threshold.png.
What do the images look like?
Open the PNG files in your file browser or an image viewer to see the pipeline's visual progression.
- output_00_original.png: A smooth red-green gradient with a blue-gray tint across the whole image.
- output_01_BlackLevelCorrection.png: Similar gradient but with slightly boosted contrast. The darkest pixels have been pulled lower and the range rescaled.
- output_02_GrayscaleConversion.png: The color is gone. You see a smooth grayscale gradient.
- output_03_BrightnessContrast.png: The grayscale gradient is slightly brighter and punchier from the brightness and contrast adjustments.
- output_04_Threshold.png: A stark black-and-white image. Every pixel is either pure black (0) or pure white (255), split at the threshold value.
Don't see any output files?
- Make sure you ran ./mini_isp from inside the build directory. The PPM files are written to whatever directory you run the executable from.
- If the convert command fails, confirm ImageMagick is installed by running convert --version. If not found, install it with sudo apt install -y imagemagick.
Why can't I see my pipeline output images?
Your ISP pipeline is fully operational. You have a modular architecture where each stage inherits from an abstract base class, the Pipeline owns its stages through smart pointers, and every intermediate result is saved as a viewable image. Next up, the Secret Mission challenges you to add a multithreaded Gaussian blur filter using pthreads.
Secret mission
Add Multithreaded Gaussian Blur with pthreads
Your pipeline processes pixels one at a time, but real ISP hardware runs operations in parallel across the image. In this challenge, you'll build a Gaussian blur filter that divides the image into horizontal strips and processes them simultaneously using POSIX threads.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on WSL 2, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files and installed packages, or remove them entirely.
Resources you used:
- mini-isp/ project folder on your Desktop, including source code, build artifacts, and output images.
- g++, cmake, make, and imagemagick packages installed on WSL 2.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your ISP pipeline or adding more processing stages.
- Your mini-isp/ folder and all output images remain on your machine.
- The g++, cmake, make, and imagemagick packages stay installed in WSL 2 and are useful for other C++ projects.
Pause - I'll come back to this later
Free up disk space by removing build artifacts while keeping your source code intact.
- Delete just the build directory and generated output files by running:
- Your source files stay in mini-isp/. When you return, rebuild with mkdir build && cd build && cmake .. && cmake --build . to pick up right where you left off.
rm -rf ~/Desktop/mini-isp/build
Delete - I don't want to use this again
Remove all project files and optionally uninstall the packages you installed for this project.
- Remove the entire project folder by running:
rm -rf ~/Desktop/mini-isp
- Optionally, remove the packages you installed for this project by running:
sudo apt remove -y g++ cmake make imagemagick
Keep your dev tools
If you plan to work on other C++ projects, consider keeping g++ and cmake installed. They are common tools you will use again.
Nice Work!
Nice Work!
You just built a modular image signal processing pipeline in C++17 from scratch. Your pipeline takes raw pixel data through a chain of processing stages and outputs viewable images at every step.
You've learned how to:
- Design a modular pipeline architecture using an abstract PipelineStage base class with pure virtual methods, std::unique_ptr ownership, and std::vector stage management.
- Implement four concrete image processing stages (black level correction, grayscale conversion, brightness/contrast adjustment, and thresholding) using C++17 features like std::clamp and proper exception handling.
- Build and run the entire project with CMake targeting C++17 on WSL 2, producing intermediate PPM images to visually verify each transformation stage.
- Secret Mission: Added a multithreaded Gaussian blur filter using pthreads, dividing image rows across threads for parallel convolution.
Ready to quiz yourself?