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
endTo 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.
@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)
endOur 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.
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
endlet
nr, nc = 72, 72
gray_figure(scene(nr, nc), nr, nc)
endUsing 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.
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]
endConvolve 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
endBelow 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?
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
endYou 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?
@bind filter_choice PlutoUI.Slider(1:5; default=2, show_value=true)filter strength
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)
endlet
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)
endBlur 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
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)
endFiltering 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)
endSummary
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.
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