C++ Image Editor (CLI version) Project
Load a PPM image and apply real filters: greyscale, blur, edge detection.
What you will build
Work with images as pure data: read a PPM file into a pixel matrix, then implement greyscale, brightness, blur (box and Gaussian), sharpen, and edge detection via convolution kernels. No libraries — you'll learn exactly what a photo filter is: arithmetic on neighbours.
What you will learn
- Parsing the PPM (P3/P6) format
- Images as 2D arrays of RGB pixels
- Convolution kernels for blur/sharpen/edges
- Clamping and normalising pixel values
- Writing valid image files back
Feature checklist
- Loads and saves PPM images
- Filters: greyscale, invert, sepia
- Brightness/contrast adjustment
- Box blur and Gaussian blur (3×3 kernel)
- Edge detection (Sobel or simple kernel)
- Rotate and flip operations
Starter code
#include <vector>
#include <fstream>
#include <algorithm>
struct Pixel { int r, g, b; };
struct Image {
int w, h;
std::vector<Pixel> px; // row-major
Pixel& at(int x, int y) { return px[y * w + x]; }
const Pixel& at(int x, int y) const { return px[y * w + x]; }
};
Image greyscale(const Image& in) {
Image out = in;
for (auto& p : out.px) {
int v = (p.r * 299 + p.g * 587 + p.b * 114) / 1000; // luminance
p = {v, v, v};
}
return out;
}
// Box blur via 3x3 kernel — the template for every
// convolution filter (sharpen, emboss, edge detect):
Image blur(const Image& in) {
Image out = in;
for (int y = 1; y < in.h - 1; ++y)
for (int x = 1; x < in.w - 1; ++x) {
int r = 0, g = 0, b = 0;
for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx) {
const Pixel& p = in.at(x + dx, y + dy);
r += p.r; g += p.g; b += p.b;
}
out.at(x, y) = {r / 9, g / 9, b / 9};
}
return out;
}Full walkthrough
Step 1: Work in PPM so the format never fights you
PPM (P6 binary or P3 text) is an image format you can fully hold in your head: a small header ('P6', width, height, max value 255) then raw RGB bytes. No compression, no library, no libpng archaeology — all your effort goes into image PROCESSING, which is the point of the project. Any image can be converted to PPM with free tools.
Step 2: Model the image as pixels you can address
struct Pixel { uint8_t r, g, b; } and class Image { int w, h; std::vector<Pixel> px; Pixel& at(x, y) } — at() computes px[y * w + x], the row-major indexing every image library on Earth uses. Every filter below is written against at(), so the storage layout lives in exactly one function.
Step 3: Load and save with the header discipline
Read the magic (insist on P6 for the binary path), width, height, maxval, then a single whitespace byte, then exactly w × h × 3 bytes in binary mode. Save mirrors it precisely. A PPM that opens in a viewer (IrfanView, GIMP, most browsers via conversion) is your ground truth: after load+save with NO filter, the output must be byte-identical — test that round-trip before writing a single filter.
Pixel& Image::at(int x, int y) { return px[y * w + x]; }Step 4: Point filters: greyscale, invert, sepia, brightness
A point filter maps each pixel independently. Greyscale: v = 0.299r + 0.587g + 0.114b (the weights are human-eye sensitivity, not a plain average — a plain average looks washed out, and now you know why), set r=g=b=v. Invert: 255 - v per channel. Brightness: add a delta and CLAMP to [0, 255] — forgetting the clamp wraps 250 + 30 around to 24, painting psychedelic shadows nobody ordered.
Step 5: Neighbourhood filters: blur with a 3×3 kernel
Box blur replaces each pixel with the average of itself and its 8 neighbours — a convolution. Implement it as a kernel loop over offsets (-1..1, -1..1), skipping out-of-bounds neighbours (and dividing by the number actually used). CRITICAL: read from a COPY of the image while writing the result — blurring in place smears already-blurred pixels into their neighbours and the image streaks diagonally. Gaussian blur is the same machine with weighted kernel {1,2,1; 2,4,2; 1,2,1} / 16.
Step 6: Edge detection with Sobel
Sobel runs TWO kernels (Gx for horizontal change, Gy for vertical) over a greyscale copy, and the output magnitude is sqrt(gx² + gy²) per pixel. Flat areas → 0 (black), sharp boundaries → bright. It is the same convolution machinery a third time, which is the lesson: learn one kernel engine, get a shelf of filters.
Step 7: Rotate and flip as index gymnastics
Flip horizontal: at(x, y) ↔ at(w - 1 - x, y). Rotate 90°: the new image is h × w and new.at(y, w - 1 - x)... work the mapping out on a 2×3 example on paper first — rotate is where off-by-one errors breed, and a non-square test image exposes every swapped width/height instantly. Chain operations from the command line (load → filters in order → save) and test each filter alone before composing them.
Common mistakes
- Blurring in place instead of from a copy, producing directional smearing instead of uniform softness.
- Skipping the [0, 255] clamp after brightness/contrast, so values wrap around and shadows turn neon.
- Using a square test image for rotate, which hides every width/height swap the code contains.
Compile and run
g++ -std=c++17 -o image_editor image_editor.cppSample run
$ ./image_editor
Load photo.ppm (800 x 600, P6) — 1,440,000 bytes of pixels
Apply: greyscale
Apply: blur (3x3 box kernel, from a copy — no smearing)
Save photo_grey_blur.ppm (800 x 600)
$ ./image_editor
Load photo.ppm (800 x 600, P6)
Apply: brightness +30 (clamped to 0-255)
Apply: edges (Sobel) — flat sky goes black, the roofline lights up
Save photo_edges.ppm (800 x 600)
$ ./image_editor
Load photo.ppm (800 x 600, P6)
Apply: rotate 90 — dimensions swap to 600 x 800
Save photo_rot.ppm (600 x 800)Try it in your browser
No browser demo is included for this project. Build the C++ version below — that is the real program.