Snapshot / Computational Thinking Repo ↗

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
end

If 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.

rgb_figure (generic function with 1 method)

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 0.1

green 0.5

blue 1.0

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)
end

Building 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)
end

Model: 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.

scene

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
end
let
	nr, nc = 80, 80
	gray_figure(scene(nr, nc), nr, nc)
end

Inspecting 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.

pixel_at

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]
end

row 40

column 40

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)
end

The 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.

crop

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
end

crop size 20

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)
end

Process: 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 1.0

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)
end

Flipping

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)
end

Joining 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)
end

Colour 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 show green show blue

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)
end

A 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 2

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)
end

Summary

  • Images are arrays of colours: a grayscale image is a Matrix of 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.