Right, let’s talk about making your images less… well, boring. Or too busy. Or just plain weird. Image filters are the digital equivalent of slapping a filter on your vacation photos, except here we’re doing it with code, which is infinitely cooler and gives you way more control. We’re going to manipulate the very pixels themselves. Don’t worry, it’s less scary than it sounds.

The core idea is simple: you take the raw pixel data from an image—usually an array of Color objects or a memory buffer—and you run each pixel through a function to transform it. A blur function might average a pixel with its neighbors. A grayscale function will crush its vibrant color into a shade of gray. It’s pixel alchemy.

The Almighty image Package

Before you can start bending pixels to your will, you need to get them into your program. Go’s image package and its sub-packages (image/jpeg, image/png, etc.) are your gateway. The first thing you’ll do is decode an image file into an image.Image interface. This interface is brilliant because it gives you a standard way to work with any image type, but it’s also a bit of a pain because to modify it, you need to get to the actual pixel data.

package main

import (
    "image"
    "image/jpeg"
    "os"
    "log"
)

func main() {
    // Open the file
    file, err := os.Open("input.jpg")
    if err != nil {
        log.Fatal("Failed to open file:", err)
    }
    defer file.Close()

    // Decode the image. Remember, img is an image.Image interface.
    img, format, err := image.Decode(file)
    if err != nil {
        log.Fatal("Failed to decode image:", err)
    }
    log.Printf("Loaded image format: %s", format)

    // Now what? We have an img, but we can't directly change its pixels.
    // We need to convert it to a concrete type, like image.RGBA.
}

The image.Image interface gives you methods to read pixels (At(x, y)) and get its dimensions, but it doesn’t have a Set(x, y, color) method. For that, you need a concrete type that implements the draw.Image interface, which does exactly that. The most common one is image.RGBA.

Converting to Something You Can Actually Work With

So you’ve got this image.Image and you’re staring at it, frustrated that you can’t change it. Here’s the standard incantation to get a mutable copy:

// Create a new RGBA canvas the same size as the original image
bounds := img.Bounds()
width, height := bounds.Max.X, bounds.Max.Y
rgbaImage := image.NewRGBA(bounds)

// Now, the simplest filter: copy every pixel exactly.
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
    for x := bounds.Min.X; x < bounds.Max.X; x++ {
        originalColor := img.At(x, y)
        rgbaImage.Set(x, y, originalColor)
    }
}

// Congrats, you've just applied the "Identity" filter. Thrilling.

This loop is the skeleton for every pixel-operation filter. The magic happens inside the loop where you call Set.

Implementing Common Filters

Now for the fun part. Let’s implement the classics.

Grayscale: Because Color is Distracting

The most correct way to convert color to grayscale isn’t to just average the Red, Green, and Blue values. Our eyes are more sensitive to green, so the standard formula is a weighted average: 0.299*R + 0.587*G + 0.114*B. Don’t just take my word for it; this is the ITU-R BT.601 standard. See? I told you I wasn’t boring.

func grayscale(src image.Image) *image.RGBA {
    bounds := src.Bounds()
    dst := image.NewRGBA(bounds)

    for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
        for x := bounds.Min.X; x < bounds.Max.X; x++ {
            oldColor := src.At(x, y)
            r, g, b, a := oldColor.RGBA() // Returns uint32 values in range [0, 65535]
            r, g, b, a = r>>8, g>>8, b>>8, a>>8 // Scale down to [0, 255]

            // The magic formula
            gray := uint8(0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b))

            // Set the new gray color, preserving the alpha channel
            newColor := color.RGBA{R: gray, G: gray, B: gray, A: uint8(a)}
            dst.Set(x, y, newColor)
        }
    }
    return dst
}

Pitfall Alert: See those uint32 values and the bit-shifting? color.RGBA() returns 16-bit values (0-65535), but color.RGBA struct expects 8-bit values (0-255). Forgetting to shift is a classic mistake that leaves you with a very dark, very wrong image.

Box Blur: Making Things Vaguely Artistic

A simple box blur is the “my first blur” algorithm. For each pixel, you average the color values of itself and its neighbors. The larger the “kernel” (the box of neighbors you sample), the stronger the blur.

func boxBlur(src *image.RGBA, radius int) *image.RGBA {
    bounds := src.Bounds()
    dst := image.NewRGBA(bounds)

    // This is a naive implementation. It's painfully slow for large radii.
    // We're doing it this way to understand the concept. Don't use this in production.
    for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
        for x := bounds.Min.X; x < bounds.Max.X; x++ {
            var r, g, b, a, count uint32
            // Look at all pixels in the box from (x-r) to (x+r) and (y-r) to (y+r)
            for dy := -radius; dy <= radius; dy++ {
                for dx := -radius; dx <= radius; dx++ {
                    nx, ny := x+dx, y+dy
                    // Crucial: check if the neighbor is actually inside the image bounds.
                    if nx >= bounds.Min.X && nx < bounds.Max.X && ny >= bounds.Min.Y && ny < bounds.Max.Y {
                        c := src.At(nx, ny)
                        pr, pg, pb, pa := c.RGBA()
                        r += pr >> 8
                        g += pg >> 8
                        b += pb >> 8
                        a += pa >> 8
                        count++
                    }
                }
            }
            // Calculate the average
            if count > 0 {
                rAvg := uint8(r / count)
                gAvg := uint8(g / count)
                bAvg := uint8(b / count)
                aAvg := uint8(a / count)
                dst.Set(x, y, color.RGBA{R: rAvg, G: gAvg, B: bAvg, A: aAvg})
            }
        }
    }
    return dst
}

Why this is a questionable choice: This is an O(n * radius²) algorithm. It’s horrifically slow for anything but tiny radii. The Go team would probably sigh at this. In the real world, you’d use a separable kernel or a much more optimized library. But this shows you how it works, which is the point.

Overlays: Putting a Cat on Everything

An overlay involves compositing two images together. The simplest way is to use the draw.Draw function from the image/draw package. It’s your workhorse for this job.

import "image/draw"

func overlay(canvas *image.RGBA, overlay image.Image, pos image.Point) {
    // draw.Draw needs a Rectangle to define where to draw the overlay.
    overlayRect := overlay.Bounds().Add(pos)
    draw.Draw(canvas, overlayRect, overlay, image.Point{}, draw.Over)
}

The draw.Over operator is the most common—it places the new image over the old one, respecting the alpha channel of the overlay image. If your overlay image has transparency (like a PNG of a logo), the background will show through perfectly.

Saving Your Masterpiece

You’ve transformed your pixels. Now get them back out into the world.

func saveImage(img image.Image, filename string) error {
    outFile, err := os.Create(filename)
    if err != nil {
        return err
    }
    defer outFile.Close()

    // You could switch on file extension to choose encoder, but jpeg is fine for now.
    return jpeg.Encode(outFile, img, &jpeg.Options{Quality: 90})
}

Best Practice: Always, always handle your errors. An image processing batch that fails silently is a nightmare to debug. And mind your memory—processing massive images can quickly blow up your program if you’re not careful. This is the trench work. It’s not always glamorous, but it’s what separates a working prototype from something that’s actually robust.