πŸ‡―πŸ‡΅ ζ—₯本θͺž | πŸ‡ΊπŸ‡Έ English

Image Processing in C

Roll your own PPM/PGM I/O, grayscale conversion, convolution filters, and edge detection β€” with minimal external libraries.

Representing images

A digital image is a 2D array of pixel intensities:
See the multi-dim array deep dive β€” C's row-major layout is a natural fit for image buffers.

Read/write PPM & PGM (no library needed)

PGM (Portable Graymap) and PPM (Portable Pixmap) use tiny headers followed by raw pixel data β€” perfect for learning.

PGM header format

P5 # magic number (P5 binary, P2 ASCII)
4 3 # width height
255 # maximum value
<width Γ— height pixel bytes>

Write

#include <stdio.h>

int save_pgm(const char *path, int w, int h, const unsigned char *data) {
    FILE *fp = fopen(path, "wb");
    if (!fp) return -1;
    fprintf(fp, "P5\n%d %d\n255\n", w, h);
    fwrite(data, 1, (size_t)w * h, fp);
    fclose(fp);
    return 0;
}

Read

unsigned char *load_pgm(const char *path, int *w, int *h) {
    FILE *fp = fopen(path, "rb");
    if (!fp) return NULL;
    char magic[3];
    int maxval;
    if (fscanf(fp, "%2s %d %d %d", magic, w, h, &maxval) != 4) {
        fclose(fp); return NULL;
    }
    fgetc(fp);   // skip the single newline after the header
    size_t n = (size_t)(*w) * (*h);
    unsigned char *data = malloc(n);
    fread(data, 1, n, fp);
    fclose(fp);
    return data;
}

PPM (color, 3 bytes per pixel)

fprintf(fp, "P6\n%d %d\n255\n", w, h);
fwrite(rgb_data, 3, (size_t)w * h, fp);   // RGB order
Getting test images: on macOS/Linux use ImageMagick (convert input.png output.pgm), or export as PGM/PPM from GIMP or Preview.app.

Grayscale / invert / threshold

RGB β†’ grayscale

Perceptual luminance: Y = 0.299 R + 0.587 G + 0.114 B (ITU-R BT.601).
void to_gray(int w, int h,
             const unsigned char *rgb,
             unsigned char *gray) {
    for (int i = 0; i < w * h; i++) {
        int r = rgb[3*i + 0];
        int g = rgb[3*i + 1];
        int b = rgb[3*i + 2];
        gray[i] = (unsigned char)(0.299 * r + 0.587 * g + 0.114 * b);
    }
}

Invert

void invert(int w, int h, unsigned char *img) {
    for (int i = 0; i < w * h; i++)
        img[i] = 255 - img[i];
}

Threshold (binarize)

void binarize(int w, int h, unsigned char *img, int threshold) {
    for (int i = 0; i < w * h; i++)
        img[i] = (img[i] >= threshold) ? 255 : 0;
}
Picking a threshold: a fixed value of 128 is often fine, but Otsu's method picks one automatically from the histogram.

Convolution (blur / sharpen)

A convolution multiplies each pixel and its neighbors by a kernel (a grid of weights) and sums the results. It's the foundation of nearly every image filter.

Box blur (3Γ—3 mean)

1/9
1/9
1/9
1/9
1/9
1/9
1/9
1/9
1/9

Sharpen

0
-1
0
-1
5
-1
0
-1
0

Generic 3Γ—3 convolution

void convolve3(int w, int h,
               const unsigned char *src,
               unsigned char *dst,
               const double k[3][3]) {
    for (int y = 0; y < h; y++) {
        for (int x = 0; x < w; x++) {
            double sum = 0;
            for (int dy = -1; dy <= 1; dy++) {
                for (int dx = -1; dx <= 1; dx++) {
                    int ny = y + dy, nx = x + dx;
                    if (ny < 0) ny = 0;
                    if (ny >= h) ny = h - 1;
                    if (nx < 0) nx = 0;
                    if (nx >= w) nx = w - 1;
                    sum += src[ny * w + nx] * k[dy + 1][dx + 1];
                }
            }
            if (sum < 0) sum = 0;
            if (sum > 255) sum = 255;
            dst[y * w + x] = (unsigned char)sum;
        }
    }
}

double blur[3][3] = {
    {1/9.0, 1/9.0, 1/9.0},
    {1/9.0, 1/9.0, 1/9.0},
    {1/9.0, 1/9.0, 1/9.0}
};
convolve3(w, h, src, dst, blur);
Don't do it in-place: writing to the same buffer you're reading from corrupts the result. Keep src and dst separate.

Sobel edge detection

Compute horizontal and vertical gradients with two kernels, then take the magnitude.

Sobel Gx / Gy kernels

Gx:
-1
0
1
-2
0
2
-1
0
1
Gy:
-1
-2
-1
0
0
0
1
2
1

Implementation

#include <math.h>

void sobel(int w, int h,
           const unsigned char *src,
           unsigned char *dst) {
    static const int Gx[3][3] = {{-1,0,1},{-2,0,2},{-1,0,1}};
    static const int Gy[3][3] = {{-1,-2,-1},{0,0,0},{1,2,1}};

    for (int y = 1; y < h - 1; y++) {
        for (int x = 1; x < w - 1; x++) {
            int sx = 0, sy = 0;
            for (int dy = -1; dy <= 1; dy++) {
                for (int dx = -1; dx <= 1; dx++) {
                    int v = src[(y + dy) * w + (x + dx)];
                    sx += v * Gx[dy + 1][dx + 1];
                    sy += v * Gy[dy + 1][dx + 1];
                }
            }
            double mag = sqrt((double)sx * sx + (double)sy * sy);
            if (mag > 255) mag = 255;
            dst[y * w + x] = (unsigned char)mag;
        }
    }
}
Compile: gcc -O2 sobel.c -o sobel -lm. Run it on a PGM and open the output in any image viewer β€” only the edges will light up.

Histogram & contrast stretch

Counts how many pixels fall at each intensity. Useful for contrast adjustment, thresholding, and analysis.
void histogram(int w, int h,
               const unsigned char *img,
               int hist[256]) {
    for (int i = 0; i < 256; i++) hist[i] = 0;
    for (int i = 0; i < w * h; i++) hist[img[i]]++;
}

void print_histogram(const int hist[256]) {
    int maxv = 0;
    for (int i = 0; i < 256; i++) if (hist[i] > maxv) maxv = hist[i];
    for (int i = 0; i < 256; i += 4) {
        int sum = 0;
        for (int k = 0; k < 4; k++) sum += hist[i + k];
        int bars = maxv ? sum * 40 / maxv : 0;
        printf("%3d |", i);
        for (int b = 0; b < bars; b++) putchar('#');
        putchar('\n');
    }
}

Linear contrast stretch

void stretch_contrast(int w, int h, unsigned char *img) {
    int lo = 255, hi = 0;
    for (int i = 0; i < w * h; i++) {
        if (img[i] < lo) lo = img[i];
        if (img[i] > hi) hi = img[i];
    }
    if (hi == lo) return;
    for (int i = 0; i < w * h; i++) {
        img[i] = (unsigned char)((img[i] - lo) * 255 / (hi - lo));
    }
}

stb_image for PNG/JPG

The easiest way to read PNG/JPG/BMP/GIF from C is stb_image β€” a single-file, public-domain, header-only library.

Download

$ curl -O https://raw.githubusercontent.com/nothings/stb/master/stb_image.h $ curl -O https://raw.githubusercontent.com/nothings/stb/master/stb_image_write.h

Minimal example: PNG β†’ PGM

#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
    int w, h, ch;
    unsigned char *data = stbi_load(argv[1], &w, &h, &ch, 1);
    // last arg = 1: force single-channel grayscale
    if (!data) { fprintf(stderr, "load failed\n"); return 1; }

    FILE *fp = fopen("out.pgm", "wb");
    fprintf(fp, "P5\n%d %d\n255\n", w, h);
    fwrite(data, 1, (size_t)w * h, fp);
    fclose(fp);

    stbi_image_free(data);
    return 0;
}
Writing PNG: include stb_image_write.h and call stbi_write_png("out.png", w, h, 1, data, w);

Challenges

Challenge 1: Generate a checkerboard PGM
Generate a 400Γ—400 black-and-white checkerboard (50-pixel squares) and save it as PGM.
Challenge 2: Grayscale CLI tool
Build ./togray input.ppm output.pgm: read a P6 PPM, convert via the luminance formula, and write a PGM.
Challenge 3: Gaussian blur
Define a 5Γ—5 Gaussian kernel (weights that decay from the center) for a smoother blur than the box filter.
Challenge 4: Mosaic
Average each NΓ—N block and fill every pixel in that block with the average. Compare N = 1, 4, 16, and 32.
Challenge 5: Otsu's method
Pick the optimal binarization threshold automatically by maximizing between-class variance on the histogram.

See also

Review Quiz

Check your understanding of this lesson!

Q1. What information does one pixel of a typical color image hold?

An intensity value from 0 to 255 for each of R, G, and B
A single signed integer
A single ASCII character

24-bit color uses 8 bits (0–255) each for red, green, and blue. A 32-bit format adds an alpha (transparency) channel.

Q2. What is a standard formula for converting a color image to grayscale?

0.299*R + 0.587*G + 0.114*B
(R+G+B)^2
R * G * B

The weights match human eye sensitivity β€” this is the standard ITU-R BT.601 conversion.

Q3. Which filter is commonly used for edge detection?

Derivative filters such as Laplacian or Sobel
Mean (averaging) filter
Identity matrix

Edges are sharp changes in brightness, so convolution kernels that approximate derivatives highlight them.