A command-line PPM image filter tool that applies grayscale, invert, blur, sharpen, Sobel edge detection, sepia, and brightness adjustments to P6 binary PPM files — with a live progress bar on stderr.
Written in pure C with no external dependencies. Part of the Corg-Labs collection.
--grayscale— luminance-weighted grayscale conversion--invert— colour inversion (255 - channel)--blur— 3x3 box blur--sharpen— unsharp-mask sharpening kernel--edge— Sobel operator edge detection--sepia— warm sepia tone transformation--brightness N— additive brightness adjustment (-100 to +100)- Progress bar on stderr for every filter
- Edge-clamping for convolution at image borders
- Reads and writes standard P6 (binary) PPM files
Each pixel holds three unsigned char channels. The image stores its dimensions and a flat row-major data array.
typedef struct { unsigned char r, g, b; } Pixel;
typedef struct {
int width;
int height;
int maxval;
Pixel *data; /* data[y * width + x] */
} Image;A clamp_u8 helper prevents overflow when arithmetic pushes channel values outside [0, 255]:
static inline unsigned char clamp_u8(int v) {
if (v < 0) return 0;
if (v > 255) return 255;
return (unsigned char)v;
}PPM P6 files have a plain-text header followed by raw binary pixel data. The reader skips whitespace and # comment lines between header fields, then bulk-reads all pixels with a single fread.
/* Header: "P6\n<width> <height>\n<maxval>\n" then raw bytes */
if (fread(magic, 1, 2, fp) != 2 || strncmp(magic, "P6", 2) != 0)
return NULL; /* not a P6 file */
fscanf(fp, "%d", &img->width);
fscanf(fp, "%d", &img->height);
fscanf(fp, "%d", &img->maxval);
fgetc(fp); /* consume the single whitespace byte after maxval */
size_t npix = (size_t)img->width * img->height;
img->data = malloc(npix * sizeof(Pixel));
fread(img->data, 3, npix, fp);Writing is the mirror: fprintf the header, fwrite the pixel array.
Human eyes are most sensitive to green and least sensitive to blue. The standard ITU-R BT.709 coefficients weight each channel accordingly.
static void filter_grayscale(Image *img) {
int n = img->width * img->height;
for (int i = 0; i < n; i++) {
unsigned char g = clamp_u8((int)(
0.2126 * img->data[i].r +
0.7152 * img->data[i].g +
0.0722 * img->data[i].b));
img->data[i].r = img->data[i].g = img->data[i].b = g;
}
}A naive average ((r+g+b)/3) gives technically correct results but produces a flat, less perceptually accurate grey.
Blur, sharpen, and edge detection all work by convolution: for each pixel, multiply each neighbour's channels by the corresponding kernel weight and sum. The result replaces the centre pixel.
The box blur kernel weights every neighbour equally (1/9 each):
static void filter_blur(Image *img) {
Image *out = alloc_image(img->width, img->height, img->maxval);
int w = img->width, h = img->height;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int sr = 0, sg = 0, sb = 0;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
Pixel p = get_pixel_clamped(img, x+dx, y+dy);
sr += p.r; sg += p.g; sb += p.b;
}
}
out->data[y*w+x].r = clamp_u8(sr / 9);
/* ... g, b similarly ... */
}
}
memcpy(img->data, out->data, (size_t)w * h * sizeof(Pixel));
free_image(out);
}The sharpening kernel is the unsharp-mask: emphasise the centre (weight 5), subtract the four orthogonal neighbours (weight -1):
static const int K[3][3] = {{ 0,-1, 0},
{-1, 5,-1},
{ 0,-1, 0}};When the convolution window extends outside the image at the edges, get_pixel_clamped clamps the coordinates to the nearest valid pixel. This avoids reading out-of-bounds memory and produces natural-looking borders.
static inline Pixel get_pixel_clamped(const Image *img, int x, int y) {
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x >= img->width) x = img->width - 1;
if (y >= img->height) y = img->height - 1;
return img->data[y * img->width + x];
}The Sobel operator uses two 3x3 kernels — Gx for horizontal gradients and Gy for vertical gradients. The edge strength at each pixel is sqrt(Gx^2 + Gy^2).
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 each pixel: */
int gxr = 0, gyr = 0; /* ... and for g, b channels */
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++) {
Pixel p = get_pixel_clamped(img, x+dx, y+dy);
gxr += Gx[dy+1][dx+1] * p.r;
gyr += Gy[dy+1][dx+1] * p.r;
}
out->data[y*w+x].r = clamp_u8((int)sqrt((double)(gxr*gxr + gyr*gyr)));High-contrast borders produce large gradient magnitudes (bright), flat regions produce near-zero values (dark).
Sepia applies a fixed 3x3 colour-matrix transform that maps RGB to a warm reddish-brown:
img->data[i].r = clamp_u8((int)(r*0.393 + g*0.769 + b*0.189));
img->data[i].g = clamp_u8((int)(r*0.349 + g*0.686 + b*0.168));
img->data[i].b = clamp_u8((int)(r*0.272 + g*0.534 + b*0.131));Brightness is a simple additive delta (user provides -100..100, which is scaled to -255..255):
img->data[i].r = clamp_u8(img->data[i].r + delta);gcc imgfilter.c -o imgfilter -lm
./imgfilter in.ppm out.ppm --grayscale
./imgfilter in.ppm out.ppm --blur
./imgfilter in.ppm out.ppm --edge
./imgfilter in.ppm out.ppm --sharpen
./imgfilter in.ppm out.ppm --sepia
./imgfilter in.ppm out.ppm --invert
./imgfilter in.ppm out.ppm --brightness 30
./imgfilter in.ppm out.ppm --brightness -50
To convert a JPEG/PNG to PPM first: convert photo.jpg photo.ppm (requires ImageMagick).
- PPM P6 binary file format: header parsing and bulk pixel I/O
- Flat row-major pixel array (
y * width + xindexing) - Convolution with arbitrary 3x3 kernels (blur, sharpen, Sobel)
- Luminance-weighted greyscale conversion (BT.709 coefficients)
- Edge-clamping as a simple boundary condition strategy
- Sobel gradient magnitude for edge detection
clamp_u8to prevent channel overflow in arithmetic filters- In-place vs. double-buffered filter application
Standard C libraries only: stdio.h, stdlib.h, string.h, math.h, ctype.h