Images as examples of data all around us
Welcome to Computational Thinking with Julia!
An image is one of the most familiar kinds of data. In this notebook we explore the central idea: an image is just an array (a grid) of numbers. Each cell of the grid is a pixel — a tiny block of a single color.
To stay completely self-contained (this notebook compiles to a live interactive WebAssembly island), we will not load a photo from disk. Instead we build our images from scratch with plain Julia loops — gradients, rings, color channels — and then index, slice, brighten, flip and blur them, exactly as we would a real photo.
begin
using PlutoUI
using WasmMakie
endIf we open an image on our computer and zoom in enough, we see that it consists of many tiny squares, or pixels ("picture elements"). Each pixel is a single colour, and the pixels are arranged in a two-dimensional grid.
These pixels are stored in the computer numerically, usually in RGB (red, green, blue) format: three numbers between 0 and 1 giving the amount of each colour. A grayscale pixel needs just a single number between 0 (black) and 1 (white).
So: an image is a matrix of numbers. Let's make some.
Representing colors
We represent a colour as an RGB triple $(r, g, b)$: three numbers between 0 (none) and 1 (full). White is $(1,1,1)$, black is $(0,0,0)$, pure red is $(1,0,0)$.
Drag the sliders to mix a colour. The cell below builds a 1-pixel image from the three numbers — a single coloured square.
red
green
blue
let
r = Float64(test_r); g = Float64(test_g); b = Float64(test_b)
# one pixel = a length-1 flat RGBA vector
pix = Vector{NTuple{4,Float64}}(undef, 1)
pix[1] = (r, g, b, 1.0)
rgb_figure(pix, 1, 1; px = 120)
endBuilding an image with loops (a colour gradient)
If we want more than a few pixels, we automate the process with a loop. Here we sweep the red and green channels across a grid: red grows from left to right, green from bottom to top. Every pixel's colour is computed from its coordinates (i, j) — pure data, no photo needed.
let
nr, nc = 48, 48
pix = Vector{NTuple{4,Float64}}(undef, nr * nc)
for i in 1:nr, j in 1:nc
r = (j - 1) / (nc - 1) # red increases with column
g = (i - 1) / (nr - 1) # green increases with row
# store column-major with row 1 at the top
pix[j + (nr - i) * nc] = (r, g, 0.4, 1.0)
end
rgb_figure(pix, nr, nc)
endModel: creating a synthetic "scene"
Movie frames (think Pixar) are images generated entirely from mathematics. Let's do a tiny version: a grayscale scene made of concentric rings around a centre, plus a soft diagonal gradient. Everything is a function of the pixel coordinates (i, j).
This scene(nr, nc) matrix is the "photo" we'll inspect and transform for the rest of the notebook.
Build a synthetic grayscale image as a flat column-major Vector{Float64} of length nr*nc. Concentric rings around the centre + a gentle gradient.
"""Build a synthetic grayscale image as a flat column-major Vector{Float64}
of length nr*nc. Concentric rings around the centre + a gentle gradient."""
function scene(nr::Int, nc::Int)
vals = Vector{Float64}(undef, nr * nc)
ci = (nr + 1) / 2
cj = (nc + 1) / 2
maxr = sqrt(ci * ci + cj * cj)
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.9) # concentric rings
grad = (i + j) / (nr + nc) # diagonal gradient
v = 0.6 * rings + 0.4 * grad
v < 0.0 && (v = 0.0)
v > 1.0 && (v = 1.0)
vals[j + (nr - i) * nc] = v
end
return vals
endlet
nr, nc = 80, 80
gray_figure(scene(nr, nc), nr, nc)
endInspecting your data
Image size
The first thing we usually want to know is the size of the image: how many rows (height) and columns (width). For our scene that is simply the (nr, nc) we chose.
scene_size = (80, 80)Locations in an image: indexing
To refer to one pixel we give two whole numbers: the row (from the top) and the column (from the left). In Julia rows and columns are numbered from 1.
Because we store the image as a flat column-major vector with row 1 on top, the pixel at row i, column j lives at index j + (nr - i) * nc. The helper pixel_at does that arithmetic for us and returns the brightness there.
Return the grayscale value at row i, column j of a flat (nr×nc) image.
"Return the grayscale value at row i, column j of a flat (nr×nc) image."
function pixel_at(vals::Vector{Float64}, nr::Int, nc::Int, i::Int, j::Int)
return vals[j + (nr - i) * nc]
endrow
column
let
nr, nc = 80, 80
v = pixel_at(scene(nr, nc), nr, nc, row_i, col_i)
# show that single pixel as a grayscale square
one = Vector{Float64}(undef, 1)
one[1] = v
gray_figure(one, 1, 1; px = 120)
endThe pixel at row 40, column 40 has brightness shown above.
Range indexing: slicing a sub-region
Instead of one pixel we can grab a block of rows and columns — a crop. With a real array we'd write img[r0:r1, c0:c1]. Here we copy the chosen rectangle into a new, smaller flat image with crop.
Use the slider to change how big a square we cut out of the centre of the scene.
Copy the rectangle rows r0:r1, cols c0:c1 out of a flat (nr×nc) image into a new flat image of size (r1-r0+1)×(c1-c0+1).
"""Copy the rectangle rows r0:r1, cols c0:c1 out of a flat (nr×nc) image into
a new flat image of size (r1-r0+1)×(c1-c0+1)."""
function crop(vals::Vector{Float64}, nr::Int, nc::Int,
r0::Int, r1::Int, c0::Int, c1::Int)
on = r1 - r0 + 1
om = c1 - c0 + 1
out = Vector{Float64}(undef, on * om)
for i in 1:on, j in 1:om
src = pixel_at(vals, nr, nc, r0 + i - 1, c0 + j - 1)
out[j + (on - i) * om] = src
end
return out
endcrop size
let
nr, nc = 80, 80
mid = 40
r0 = mid - crop_half; r1 = mid + crop_half
c0 = mid - crop_half; c1 = mid + crop_half
on = r1 - r0 + 1; om = c1 - c0 + 1
sub = crop(scene(nr, nc), nr, nc, r0, r1, c0, c1)
gray_figure(sub, on, om)
endProcess: modifying an image
Now that an image is just numbers, we can process it. Every operation below is a loop that reads pixels and writes new ones.
Brightness
The simplest transform: scale every pixel by a factor. A factor below 1 darkens the image, above 1 brightens it (clamped to 1). Drag the slider.
brightness
let
nr, nc = 80, 80
base = scene(nr, nc)
out = Vector{Float64}(undef, nr * nc)
f = Float64(brightness)
for k in 1:(nr * nc)
v = base[k] * f
v > 1.0 && (v = 1.0)
out[k] = v
end
gray_figure(out, nr, nc)
endFlipping
Flipping is pure index gymnastics: to flip left↔right we read column nc - j + 1 instead of column j; to flip top↕bottom we read row nr - i + 1. No pixel values change — only where they go.
flip left↔right
flip top↕bottom
let
nr, nc = 80, 80
base = scene(nr, nc)
out = Vector{Float64}(undef, nr * nc)
for i in 1:nr, j in 1:nc
si = flip_tb ? (nr - i + 1) : i
sj = flip_lr ? (nc - j + 1) : j
out[j + (nr - i) * nc] = pixel_at(base, nr, nc, si, sj)
end
gray_figure(out, nr, nc)
endJoining images (concatenation)
We often place images side by side. Here we build the four-up mosaic [ A flip-LR ; flip-TB flip-both ] — the classic "kaleidoscope" you get by mirroring an image into a 2×2 block.
let
nr, nc = 64, 64
base = scene(nr, nc)
on = 2 * nr; om = 2 * nc
out = Vector{Float64}(undef, on * om)
for i in 1:nr, j in 1:nc
v = pixel_at(base, nr, nc, i, j)
# top-left: original
out[j + (on - i) * om] = v
# top-right: mirror left↔right
out[(j + nc) + (on - i) * om] = v
# bottom-left: mirror top↕bottom
out[j + (on - (i + nr)) * om] = v
# bottom-right: mirror both
out[(j + nc) + (on - (i + nr)) * om] = v
end
# now actually mirror the right/bottom halves by re-reading flipped sources
for i in 1:nr, j in 1:nc
out[(j + nc) + (on - i) * om] = pixel_at(base, nr, nc, i, nc - j + 1)
out[j + (on - (i + nr)) * om] = pixel_at(base, nr, nc, nr - i + 1, j)
out[(j + nc) + (on - (i + nr)) * om] =
pixel_at(base, nr, nc, nr - i + 1, nc - j + 1)
end
gray_figure(out, on, om; px = 360)
endColour channels
A colour image is really three grayscale images stacked: one for red, one for green, one for blue. Here we build an RGB scene from three different synthetic patterns, then let you switch a channel on or off to see its contribution.
show red
let
nr, nc = 80, 80
ci = (nr + 1) / 2; cj = (nc + 1) / 2
pix = Vector{NTuple{4,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)
r = show_r ? (j - 1) / (nc - 1) : 0.0 # red: horizontal ramp
g = show_g ? (i - 1) / (nr - 1) : 0.0 # green: vertical ramp
b = show_b ? (0.5 + 0.5 * cos(dist * 0.6)) : 0.0 # blue: rings
pix[j + (nr - i) * nc] = (r, g, b, 1.0)
end
rgb_figure(pix, nr, nc)
endA simple blur (convolution)
A blur replaces each pixel with the average of itself and its neighbours. We slide a small square window (the kernel) over the image; the bigger the window, the blurrier the result. This averaging is the simplest example of a convolution — the workhorse of image processing.
Drag the blur radius slider: radius 0 is the original scene, larger radii average over wider neighbourhoods.
blur radius
let
nr, nc = 80, 80
base = scene(nr, nc)
rad = 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)
endSummary
Images are arrays of colours: a grayscale image is a
Matrixof numbers; a colour image stacks three (red, green, blue) channels.We inspect and modify arrays with indexing (one pixel), range indexing / cropping (a sub-region), and whole-array loops.
We can build images directly from their coordinates (gradients, rings — a synthetic scene), with no photo required.
Classic transforms — brightness scaling, flips, concatenation, splitting into colour channels, and a blur (the simplest convolution) — are all just loops over the grid.
Every figure above is a live WebAssembly island rendered with WasmMakie, recomputed in your browser as you move the sliders.