The workshop
Built in an afternoon

Filter Generator

A picture is just numbers.

A small desktop app that gives a whole pile of photos a retro film look in one go: seven sliders, a before and after, and one button that runs the batch in the background. It came from an aha moment — an image is a grid of numbers, and numbers are something I can change with code.

img_arr.shape (600, 900, 3)
Role
Design & development, solo
Built for
Me, for fun
Built
May 2026, two afternoons
Stack
Python · NumPy · Pillow

01Why it exists

A picture is just data. And data is something I can change with code.

I made this for fun: I wanted to put filters on my own photos. Somewhere along the way it clicked that a picture is just data — numbers for every pixel, arranged so that our eyes read them as an image. Everything on a screen is code, and if it’s code, I can change it. That was my aha moment.

The first try was Pixelated Kisses, made for one look: a Joji album cover. It turns a photo into black, a warm grey and a hot red, adds grain, and can pixelate it. It’s on GitHub (opens in a new tab), with ready-made downloads for Mac and Windows.

Two weeks later I wanted something more general — a retro film look I could tune — and I wanted it for a whole folder at once, not one photo at a time. That became Filter Generator: seven sliders, eight steps, one click for the whole pile.

It stayed with me, too. This little app is one of the things that started RideLens, the car photo editor I’m building for iOS.

8 May · Pixelated Kisses

One look, two sliders

  • A gradient map: each pixel’s brightness picks a colour from black, through a warm grey, to red
  • Colour grain — separate noise on red, green and blue
  • A pixel size from 1 to 50, filled with the average of each block
  • Released as macOS and Windows downloads, built by GitHub Actions
23 May · Filter Generator

A film look you can tune

  • Eight steps, from a colour lookup table to a glow around highlights
  • Seven sliders, with defaults that already give the look
  • Monochrome grain, like film
  • A whole batch on a background thread, with a progress bar

02Try it

Move a slider. Watch the numbers move.

This is the app’s filter, ported line by line from Python and running on your device: the same eight steps, in the same order, rounded the same way after every one. Point at the photo to follow one pixel through the whole pipeline.

Preview size

Filter settings

100
60
−20
75
70
40
40
Listed as in the app — applied in pipeline order.

Pipeline · one pixel, step by step

Pixel —

    Hover or tap the photo to read a pixel; arrow keys move it one pixel at a time (Shift for ten).

    A line-for-line port of the app’s apply_filter_logic() and of Pixelated Kisses’ filter, checked byte for byte against NumPy and Pillow, running in a background worker. It runs the same maths at preview size (900 px wide), so blur, grain and glow — all measured in pixels — look stronger than on a full-size photo. The grain is random as in the app, but drawn once per photo and rescaled as you drag, so it doesn’t flicker; “New grain” draws a fresh set. Your own photo never leaves this page.

    03What it does

    Small app. Whole pile.

    Drop a pile

    Drag photos anywhere onto the window, or pick them: JPG, PNG, BMP and WebP. Phone photos are turned upright from their EXIF data as they load.

    Seven sliders

    Grain, warmth, brightness, shadow fade, lens softness, glow and vignette — each with its value printed above it, and sensible defaults.

    One click for all of them

    Every photo runs through the filter on a background thread while a red progress bar fills, so the window stays responsive.

    Before and after

    Flip between the original and the result, and step through the batch with Prev and Next — “Image 2 of 5”.

    Save one, or a folder

    One photo opens a Save dialog. Several go into a folder you choose, each as name_filtered.jpg. Restart clears the lot.

    The first one shipped

    Pixelated Kisses went out as zipped builds for macOS and Windows on GitHub Releases, made by GitHub Actions with PyInstaller.

    04How it’s built

    NumPy in. uint8 out.

    One Python file of 535 lines. Pillow opens, blurs, resizes and saves the photos; everything else is arithmetic on a NumPy array of pixels.

    InterfaceCustomTkinter in dark mode, with tkinterdnd2 for drag and drop. Dropped paths with spaces arrive wrapped in braces; a regex splits them.
    PixelsEach photo is an array of shape (height, width, 3). Warmth, brightness, fade, vignette and grain are array maths; blur, aberration and glow go through Pillow.
    PrecisionEvery step ends with astype(np.uint8) — back to whole numbers from 0 to 255 before the next one starts.
    ThreadsProcessing runs on a daemon thread and reports progress through Tkinter’s after(), back on the UI thread.
    Python 3.12NumPyPillowCustomTkintertkinterdnd2
    main.py — apply_filter_logic()
    # 4. Vignette
    if vignette > 0:
        v_factor = vignette / 100.0
        h, w, _ = img_arr.shape
        y_coords = np.linspace(-1.0, 1.0, h)
        x_coords = np.linspace(-1.0, 1.0, w)
        Y, X = np.meshgrid(y_coords, x_coords, indexing='ij')
        dist = np.sqrt(X**2 + Y**2) / np.sqrt(2.0)
        mask = np.clip(1.0 - (v_factor * 0.45) * (dist ** 2), 0.0, 1.0)
        img_arr = (img_arr.astype(np.float32) * mask[:, :, np.newaxis])
        img_arr = np.clip(img_arr, 0, 255).astype(np.uint8)
    
    # 5. Film Grain (Monochromatic Noise)
    if grain > 0:
        h, w, _ = img_arr.shape
        noise_scale = (grain / 100.0) * 20.0
        noise = np.random.normal(loc=0, scale=noise_scale, size=(h, w, 1))
        img_arr = np.clip(img_arr.astype(np.float32) + noise, 0, 255).astype(np.uint8)

    05Build notes

    Eight steps, all of them arithmetic.

    01

    The colour grade is a lookup table

    Warmth doesn’t visit pixels one by one. The app works out 256 answers per channel once — a straight line blended with a retro curve — then applies them with NumPy indexing: lut_r[img_arr[:, :, 0]]. Even at warmth 0, floating point leaves 24 of those 256 values one lower than they went in.

    02

    Film grain is monochrome

    Filter Generator draws one random number per pixel and adds it to red, green and blue alike, so the grain changes brightness, not colour. Pixelated Kisses drew separate noise for each channel, which speckles the image with colour.

    03

    Lens fringes from resizing

    Chromatic aberration is the three channels pulled apart: red scaled up by as much as 1.4% and cropped back, blue scaled down and centred on black, green left alone. It rides on the Lens Softness slider, so a softer lens also fringes more.

    04

    Glow only from the highlights

    Only pixels with a luma above 180 feed the bloom. That layer is blurred by 5 to 25 pixels and added back at up to 75% strength — so a halo forms around bright paint and lights, not over the whole picture.