Kelick Tech Hub

Build, learn & keep up — tech, Linux & sports news included.

C++ Image Editor (CLI version) Project

Advanced•1–2 weeks•image formats, PPM, filters

Load a PPM image and apply real filters: greyscale, blur, edge detection.

Share on X

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

Feature checklist

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

Compile and run

g++ -std=c++17 -o image_editor image_editor.cpp

Sample 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.

Related Advanced projects

← All 60 projects · Classic site (Learn & Sandbox)

Download Fake GPS