Snapshot / Computational Thinking Repo ↗

Module 1 · §4 — the image-filtering core of MIT Computational Thinking's "Transformations with Images" lecture, here as a self-contained WebAssembly island. Adapted from Boshra Ariguib's Images and Filtering Pluto featured notebook.

Images as Lists of numbers

Hi There! Remember when you were a kid and you used to play with legos? You probably would put tiny pieces of different colors together to form different shapes and elements.

Well, images in computers work exactly the same way! Each image is made of tiny elements we call pixels. Since our computers only understand numbers, these pixels are given to the computer as a list of numbers.

In the following, we will explore how images can be processed in computer science, and we will introduce a cool tool called filtering: a mathematical operation that modifies images by smoothing them, highlighting some parts, and much more.

To keep everything self-contained — this notebook compiles to a live interactive WebAssembly island — we won't load a photo from disk. Instead we build our image from scratch with plain Julia loops, then filter it.

begin
	using PlutoUI, WasmMakie
end

To keep things simple, we will only deal with black and white images for now. So, for a black and white image, each pixel is a single number between 0 (black) and 1 (white).

Try it out! Move the slider around to set the pixel below to get a different shade of gray.

0.5
@bind g PlutoUI.Slider(0.0:0.05:1.0; default=0.5, show_value=true)
let
	gv = Float64(g)
	one = Vector{Float64}(undef, 1)
	one[1] = gv
	gray_figure(one, 1, 1; px = 80)
end

Our image: a synthetic scene

Now we can fill a grid of pixels and we already have our first image! Rather than load a photo, we generate one from mathematics — exactly how movie frames (think Pixar) are made. Our scene(nr, nc) is a grayscale image of concentric rings around the centre, plus a soft diagonal gradient and a couple of crisp blocks. Every pixel is just a function of its coordinates (i, j).

This flat, column-major Vector{Float64} is the "photo" we will filter for the rest of the notebook.

scene

Build a synthetic grayscale image as a flat column-major Vector{Float64} of length nr*nc: concentric rings + a gradient + two crisp blocks.

"""Build a synthetic grayscale image as a flat column-major Vector{Float64}
of length nr*nc: concentric rings + a gradient + two crisp blocks."""
function scene(nr::Int, nc::Int)
	vals = Vector{Float64}(undef, nr * nc)
	ci = (nr + 1) / 2
	cj = (nc + 1) / 2
	for i in 1:nr, j in 1:nc
		di = i - ci
		dj = j - cj
		dist = sqrt(di * di + dj * dj)
		rings = 0.5 + 0.5 * cos(dist * 0.8)        # concentric rings
		grad = (i + j) / (nr + nc)                 # diagonal gradient
		v = 0.55 * rings + 0.45 * grad
		# two crisp blocks to make edge filters pop
		if i >= 12 && i <= 24 && j >= 12 && j <= 24
			v = 0.95
		end
		if i >= 44 && i <= 60 && j >= 46 && j <= 62
			v = 0.1
		end
		v < 0.0 && (v = 0.0)
		v > 1.0 && (v = 1.0)
		vals[j + (nr - i) * nc] = v
	end
	return vals
end
let
	nr, nc = 72, 72
	gray_figure(scene(nr, nc), nr, nc)
end

Using a filter

Now to make things more interesting, let's apply a filter to this image.

A filter is a small matrix (here a 3×3 grid) that describes how to compute the new value of a pixel. Our code goes over every pixel and applies the mathematical operation of convolution: the pixel and its eight neighbours are each multiplied by the matching value in the filter, and the results are added up.

It's alright if you don't follow every line of the code below — the pictures will make it clear.

pixel_at

Return the grayscale value at row i, column j of a flat (nr×nc) image, clamped to the border.

"Return the grayscale value at row i, column j of a flat (nr×nc) image, clamped to the border."
function pixel_at(vals::Vector{Float64}, nr::Int, nc::Int, i::Int, j::Int)
	ii = i
	jj = j
	ii < 1 && (ii = 1)
	ii > nr && (ii = nr)
	jj < 1 && (jj = 1)
	jj > nc && (jj = nc)
	return vals[jj + (nr - ii) * nc]
end
convolve

Convolve a flat (nr×nc) image with a flat 3×3 kernel k (row-major, length 9). Out-of-bounds neighbours are clamped to the border, so the result keeps the original size. Values are clamped to 0..1 for display.

"""Convolve a flat (nr×nc) image with a flat 3×3 kernel `k` (row-major, length 9).
Out-of-bounds neighbours are clamped to the border, so the result keeps the
original size. Values are clamped to 0..1 for display."""
function convolve(vals::Vector{Float64}, nr::Int, nc::Int, k::Vector{Float64})
	out = Vector{Float64}(undef, nr * nc)
	for i in 1:nr, j in 1:nc
		acc = 0.0
		for di in -1:1, dj in -1:1
			w = k[(di + 1) * 3 + (dj + 2)]      # row-major 3×3 index
			acc += w * pixel_at(vals, nr, nc, i + di, j + dj)
		end
		acc < 0.0 && (acc = 0.0)
		acc > 1.0 && (acc = 1.0)
		out[j + (nr - i) * nc] = acc
	end
	return out
end

Below you can choose a filter and dial its strength. The classic kernels are:

  • Identity — leaves the image unchanged (a 1 in the centre).

  • Box blur — every weight equals 1/9, so each pixel becomes the average of its 3×3 neighbourhood. Blurring!

  • Sharpen — boosts the centre and subtracts the neighbours, making edges crisper.

  • Edge detect — the centre minus its neighbours; flat areas go dark and edges light up.

  • Sobel (vertical) — the workhorse gradient kernel that highlights vertical edges.

In the box blur all weights are equal, so each new pixel is just the average of the pixel and its neighbours. Can you see it in the result below?

make_kernel

Build a flat 3×3 kernel (row-major, length 9) for filter choice, scaled by strength. choice is clamped into 1..5 so any slider index is valid: 1 identity, 2 box blur, 3 sharpen, 4 edge detect, 5 Sobel vertical.

"""Build a flat 3×3 kernel (row-major, length 9) for filter `choice`, scaled by
`strength`. `choice` is clamped into 1..5 so any slider index is valid:
1 identity, 2 box blur, 3 sharpen, 4 edge detect, 5 Sobel vertical."""
function make_kernel(choice::Int, strength::Float64)
	c = choice
	c < 1 && (c = 1)
	c > 5 && (c = 5)
	s = strength
	k = Vector{Float64}(undef, 9)
	for t in 1:9
		k[t] = 0.0
	end
	if c == 1                       # identity
		k[5] = 1.0
	elseif c == 2                   # box blur (strength interpolates toward blur)
		w = (1.0 / 9.0) * s
		for t in 1:9
			k[t] = w
		end
		# top up the centre so the total weight stays 1 (keeps brightness)
		k[5] = k[5] + (1.0 - s)
	elseif c == 3                   # sharpen
		k[2] = -s; k[4] = -s; k[6] = -s; k[8] = -s
		k[5] = 1.0 + 4.0 * s
	elseif c == 4                   # edge detect (Laplacian)
		k[2] = -s; k[4] = -s; k[6] = -s; k[8] = -s
		k[5] = 4.0 * s
	else                            # Sobel vertical
		k[1] = s;  k[3] = -s
		k[4] = 2.0 * s; k[6] = -2.0 * s
		k[7] = s;  k[9] = -s
	end
	return k
end

You can also try the other filters!

Pick a filter and adjust its strength below, then watch both the filter matrix (shown as a small image) and the filtered scene change. Can you tell the blur apart from the edge detector just by looking?

2
@bind filter_choice PlutoUI.Slider(1:5; default=2, show_value=true)

filter strength 1.0

The names match the slider, in order: 1 = Identity, 2 = Box Blur, 3 = Sharpen, 4 = Edge Detect, 5 = Sobel (vertical edges).

let
	# show the chosen 3×3 kernel as a tiny image (normalised to 0..1)
	k = make_kernel(Int(filter_choice), Float64(filter_strength))
	lo = k[1]; hi = k[1]
	for t in 2:9
		k[t] < lo && (lo = k[t])
		k[t] > hi && (hi = k[t])
	end
	span = hi - lo
	span <= 0.0 && (span = 1.0)
	disp = Vector{Float64}(undef, 9)
	# kernel is row-major 3×3; gray_figure wants column-major with row 1 on top
	for r in 1:3, cc in 1:3
		v = (k[(r - 1) * 3 + cc] - lo) / span
		disp[cc + (3 - r) * 3] = v
	end
	gray_figure(disp, 3, 3; px = 150)
end
let
	nr, nc = 72, 72
	base = scene(nr, nc)
	k = make_kernel(Int(filter_choice), Float64(filter_strength))
	out = convolve(base, nr, nc, k)
	gray_figure(out, nr, nc; px = 360)
end

Blur radius

The box blur above averages over the 3×3 neighbourhood. A bigger window means a blurrier image. Below we slide the blur radius: radius 0 is the original scene, larger radii average over a wider square of neighbours. This is the simplest convolution of all — pure averaging.

blur radius 2

let
	nr, nc = 72, 72
	base = scene(nr, nc)
	rad = Int(blur_radius)
	out = Vector{Float64}(undef, nr * nc)
	for i in 1:nr, j in 1:nc
		acc = 0.0
		cnt = 0
		for di in -rad:rad, dj in -rad:rad
			ii = i + di
			jj = j + dj
			if ii >= 1 && ii <= nr && jj >= 1 && jj <= nc
				acc += pixel_at(base, nr, nc, ii, jj)
				cnt += 1
			end
		end
		out[j + (nr - i) * nc] = acc / cnt
	end
	gray_figure(out, nr, nc)
end

Filtering in colour

A colour image is really three grayscale images stacked: one for red, one for green, one for blue. Here we build an RGB scene and blur it — convolution runs on each channel independently. Toggle the blur on and off to compare.

blur the colour image

let
	nr, nc = 72, 72
	ci = (nr + 1) / 2; cj = (nc + 1) / 2
	# build three channels as flat vectors
	rr = Vector{Float64}(undef, nr * nc)
	gg = Vector{Float64}(undef, nr * nc)
	bb = Vector{Float64}(undef, nr * nc)
	for i in 1:nr, j in 1:nc
		di = i - ci; dj = j - cj
		dist = sqrt(di * di + dj * dj)
		idx = j + (nr - i) * nc
		rr[idx] = (j - 1) / (nc - 1)
		gg[idx] = (i - 1) / (nr - 1)
		bb[idx] = 0.5 + 0.5 * cos(dist * 0.6)
	end
	if color_blur
		k = make_kernel(2, 1.0)             # box blur
		rr = convolve(rr, nr, nc, k)
		gg = convolve(gg, nr, nc, k)
		bb = convolve(bb, nr, nc, k)
	end
	pix = Vector{NTuple{4,Float64}}(undef, nr * nc)
	for k2 in 1:(nr * nc)
		pix[k2] = (rr[k2], gg[k2], bb[k2], 1.0)
	end
	rgb_figure(pix, nr, nc)
end

Summary

  • Images are arrays of numbers: a grayscale image is a grid of brightnesses; a colour image stacks three (red, green, blue) channels.

  • A filter (kernel) is a tiny matrix. Convolution slides it over the image, multiplying each pixel and its neighbours by the kernel weights and summing.

  • Different kernels do different jobs: a box blur averages (smooths), a sharpen boosts the centre, and edge / Sobel kernels light up where the image changes.

  • We can build images directly from their coordinates (rings, gradients — a synthetic scene), with no photo required.

Every figure above is a live WebAssembly island rendered with WasmMakie, recomputed in your browser as you move the sliders.

rgb_figure (generic function with 1 method)
begin
	# ── WasmMakie display helpers ───────────────────────────────────────────
	# Both take a *flat, column-major* Vector and the (nrows, ncols) shape, so
	# nothing here builds a Matrix literal (those trap inside wasm kernels).
	# `gray_figure` renders a grayscale value v as the neutral colour (v, v, v)
	# through `image!` (the wasm-stable RGBA path — heatmap! is not wasm-safe).
	# Row 1 is drawn at the TOP (we flip with (nr - i)), matching image layout.

	function gray_figure(vals::Vector{Float64}, nr::Int, nc::Int; px::Int = 320)
		pix = Vector{NTuple{4,Float64}}(undef, nr * nc)
		for k in 1:(nr * nc)
			v = vals[k]
			pix[k] = (v, v, v, 1.0)          # gray = equal R, G, B
		end
		fig = Figure(size = (px, max(40, round(Int, px * nr / nc))))
		ax = Axis(fig[1, 1])
		hidedecorations!(ax)
		hidespines!(ax)
		image!(ax, (0.0, Float64(nc)), (0.0, Float64(nr)), pix,
		       Int64(nc), Int64(nr); interpolate = false)
		fig
	end

	function rgb_figure(pix::Vector{NTuple{4,Float64}}, nr::Int, nc::Int;
	                    px::Int = 320)
		fig = Figure(size = (px, max(40, round(Int, px * nr / nc))))
		ax = Axis(fig[1, 1])
		hidedecorations!(ax)
		hidespines!(ax)
		image!(ax, (0.0, Float64(nc)), (0.0, Float64(nr)), pix,
		       Int64(nc), Int64(nr); interpolate = false)
		fig
	end
end