✳ OMNIVIEWER Open the photo booth Camera write-up ← back

Learn maths and programming with a photo booth

The /camera TRANSFORM tab is a photo booth in which every effect is twenty lines of JavaScript you can edit, running on your own face, thirty times a second. That combination is unusually good for teaching: the maths is small enough to fit on a slide, and the feedback is so immediate that a wrong sign is not a mark on a worksheet, it is your nose in the wrong place. This page is the lesson plan — six effects, the arithmetic behind each one, and exercises where you predict the result before you press Apply.

On this page One idea Arithmetic Weighted averages Integer division Quantisation Coordinates Time What it teaches about code A 45-minute plan Timelapse as an instrument Try it

The whole thing rests on one idea

A picture is an array of numbers. Every frame from the camera arrives as one flat list of bytes, four per pixel — red, green, blue, and alpha (opacity) — laid out left to right, top row first:

function transform(pixels, width, height, t) {
  // pixels  Uint8ClampedArray, 4 bytes per pixel, values 0–255
  // t       seconds since you pressed Apply

  return pixels;   // ← the identity effect: change nothing, see the camera
}

The one formula worth putting on the board is the address of a pixel, because every effect on the row is built out of it:

i = (y * width + x) * 4     // the red byte of the pixel at column x, row y

pixels[i]      // red      pixels[i + 2]  // blue
pixels[i + 1]  // green    pixels[i + 3]  // alpha

That is a two-dimensional grid stored in one dimension, which is a genuine piece of mathematics and one that normally arrives dry. Here it arrives as a question you can answer by looking: multiply by 4 and you step a pixel; multiply by width * 4 and you step a row. Get the multiplication wrong and the picture shears diagonally across the screen — which is not a bug so much as a diagram of what you did.

Nothing leaves the tab. The code runs in a Web Worker on your own machine, the camera stream never goes anywhere, and there is no account, no upload and no server. That matters for a classroom: a webcam exercise that ships thirty children’s faces to a third party is not a lesson anyone should have to defend.

Lesson 1 — arithmetic on a single number

The first effect that is not the identity is Invert, and it is one line of arithmetic applied to every byte:

pixels[i] = 255 - pixels[i];   // …and the same for i + 1 and i + 2

Subtraction as a reflection: 0 becomes 255, 255 becomes 0, 128 stays put. Ask what happens at the fixed point and you have asked a real question about a function, and the answer is on the screen — the mid-greys barely move while the extremes swap.

The second thing to try is the one everybody tries anyway, and it hides a lesson worth having:

pixels[i] = pixels[i] + 60;    // brighter… up to a point
Predict, then check
  1. What happens to a pixel already at 220 when you add 60?
  2. Try it. The bright parts of the room go flat white and stay there. Why can they not go past 255?
  3. Now try pixels[i] * 1.3 instead. Which one keeps more detail in the bright areas, and why?

The answer is in the type. The array is a Uint8ClampedArray: eight bits per value, so the range is 0–255, and anything outside it is clamped to the nearest end rather than wrapping around. That is saturation arithmetic, and it is why over-exposed photographs lose detail permanently.

Lesson 2 — a weighted average that is not the obvious one

Black & white looks like it should be the mean of the three channels. It is not:

const y = 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
pixels[i] = pixels[i + 1] = pixels[i + 2] = y;

Three weights that sum to 1 — the definition of a weighted average — and green carries nearly six times the weight of blue. The reason is not arbitrary: the human eye has far more receptors for green light than for blue, so a green pixel looks brighter than a blue one of the same numeric value. The weights are the Rec. 601 luma coefficients, and they encode a fact about eyes inside a formula about numbers.

Predict, then check
  1. Change the three weights to 1/3 each and point the camera at something blue, then something green. Which one changes more?
  2. Set the weights to 1, 0, 0. You are now looking at the red channel alone. What happened to anything red in the room, and why is skin so bright?
  3. Do the weights have to sum to 1? Try 0.6, 1.2, 0.2 and explain the result using the clamping you found in Lesson 1.

Lesson 3 — integer division, and a face you cannot recognise

Pixelate is the effect everyone reaches for, and underneath it is the most useful piece of arithmetic on the page: divide the plane into blocks, average each block, paint the average back.

A webcam portrait pixelated into large square blocks, each block a single flat colour, the face no longer identifiable.
Pixelate, with MODIFIER = 24. Every 24×24 square has been replaced by one colour — the mean of the 576 pixels that used to be there. Note what survives: the position of the head, the colour of the shirt, the direction of the light. And what does not: any of the detail that makes a face a particular face. That trade is the whole subject of image compression, and it is visible in one press.
const MODIFIER = 24;                // block size, in pixels
const MODIFIER_RANGE = [1, 64];     // and how far the dial may push it

for (let by = 0; by < height; by += MODIFIER) {
  for (let bx = 0; bx < width; bx += MODIFIER) {
    // Pass one: add up every pixel in the block…
    let r = 0, g = 0, b = 0;
    for (let y = by; y < by + bh; y++) {
      let i = (y * width + bx) * 4;
      for (let x = 0; x < bw; x++, i += 4) { r += pixels[i]; g += pixels[i+1]; b += pixels[i+2]; }
    }
    const n = bw * bh;
    r /= n; g /= n; b /= n;          // …divide by how many there were: the mean
    // Pass two: paint that one colour back over the whole block.
  }
}

Two nested loops over blocks, two more over the pixels inside a block: four levels of nesting that no one has to be talked into, because the shape of the loops is the shape of the picture. And the arithmetic is exactly the mean a maths class already knows — sum, then divide by the count.

The knob is on the picture

Most of these effects turn on exactly one number, and an effect that names it MODIFIER — with a MODIFIER_RANGE beside it saying how far it may be pushed — gets a slider on the picture itself. Drag it and the block size changes while you watch, at whatever size the picture is, on a phone as well as a laptop.

The slider holds no value of its own. Every move rewrites that line in the editor and recompiles, so after dragging the dial down to 8 the code on screen says const MODIFIER = 8; — the number you felt is the number you can now read, copy and argue about. That matters for teaching: the exercises below can be done with a thumb in three seconds each, and the class still ends up looking at the constant that did it. (Declare the pair in an effect you write yourself and you get a dial too; delete it and the dial goes away.)

Predict, then check
  1. The frame is 640×360 and MODIFIER is 24. How many blocks are there across? Down? (Careful: 640 ÷ 24 is not a whole number. What does the code do with the leftover strip?)
  2. Drag the dial down to 8. At what block size does a face become recognisable again? Everybody in the room will disagree by a few pixels — that disagreement is the lesson.
  3. Drag it all the way to 1. Predict the result before you let go.
  4. Why must the code read every pixel in a block before it writes any of them? What would the picture look like if you painted as you went?

Lesson 4 — quantisation, or how many colours do you actually need

Posterize is three lines and one idea: instead of 256 possible values per channel, allow only MODIFIER of them, evenly spaced, and round each pixel to the nearest one.

const MODIFIER = 4;                       // levels per channel — the dial
const MODIFIER_RANGE = [2, 16];
const step = 255 / (MODIFIER - 1);        // 4 levels → 0, 85, 170, 255

pixels[i]     = Math.round(pixels[i] / step) * step;
pixels[i + 1] = Math.round(pixels[i + 1] / step) * step;
pixels[i + 2] = Math.round(pixels[i + 2] / step) * step;

Math.round(v / step) * step is the standard way to snap a number to a grid, and it appears everywhere once you have seen it — rounding money to the nearest cent, a slider to the nearest notch, a sensor reading to the nearest degree. Here the grid is a set of colours, so you can see it.

The same webcam portrait reduced to pure black and pure white shapes, the figure a solid silhouette against a white background.
Posterize turned all the way down: MODIFIER = 2. Each channel can now be only 0 or 255, so a bright room collapses to white and everything darker than halfway collapses to black — which is where posterizing meets the Threshold effect further along the row. Twenty-four bits per pixel have become three, and the shape of a person survives all of it.
Predict, then check
  1. Drag the dial to 2. How many different colours can the whole picture now contain? (Each channel has 2 choices, and there are 3 channels.)
  2. How many at MODIFIER = 4? At 8? Write the general formula, then check each answer with the slider.
  3. Why is step divided by MODIFIER - 1 rather than by MODIFIER? What would go wrong at the top of the range if it were not?
  4. Which effect loses more information — Posterize at 4 levels, or Pixelate at a block size of 24? Defend your answer by counting the numbers each one throws away.

Lesson 5 — coordinates, and the day trigonometry earns its keep

Everything so far changed the colour of a pixel and left it where it was. The second half of the effect row moves pixels around instead, and that means coordinates. Twirl is the one to teach with, because it is a rotation whose angle depends on the radius:

const MODIFIER = 2.4;                    // radians of rotation at the centre
const MODIFIER_RANGE = [-6.5, 6.5];      // the dial, and it crosses zero
const src = pixels.slice();              // a copy to read from — see below
const cx = width / 2, cy = height / 2;
const R = Math.min(cx, cy);

for (let y = 0; y < height; y++) {
  for (let x = 0; x < width; x++) {
    const dx = x - cx, dy = y - cy;
    const r = Math.sqrt(dx * dx + dy * dy);       // Pythagoras: distance to centre
    if (r >= R) continue;                        // outside the disc: leave it alone
    const a = Math.atan2(dy, dx) - MODIFIER * (1 - r / R); // angle, twisted
    const sx = Math.round(cx + r * Math.cos(a));          // back to x, y
    const sy = Math.round(cy + r * Math.sin(a));
    // copy the pixel at (sx, sy) to (x, y)
  }
}

That is a full conversion to polar coordinates and back, in a form a fifteen-year-old can follow: r from Pythagoras, the angle from atan2, and cos/sin to return. The twist is MODIFIER * (1 - r / R) — a linear interpolation that is 1 at the centre and 0 at the rim, which is exactly why the middle spins and the edge does not.

Predict, then check
  1. Push the dial to 6.3, near Math.PI * 2 — a full turn at the centre. Why does the middle look almost normal again?
  2. Replace (1 - r / R) with (1 - r / R) * (1 - r / R). Where does the shearing concentrate now?
  3. Drag the dial through zero into the negatives. What has changed, and why is zero the one setting that does nothing at all?
  4. Change a to Math.atan2(dy, dx) + t * 0.5. Now it spins on its own, forever. What is t?

Lesson 6 — the fourth argument is time

t is the number of seconds since you pressed Apply, as a floating-point number, and it turns every effect above into an animation. Wave is the smallest demonstration:

const shift = Math.round(Math.sin(y * 0.06 + t * 3) * 12);   // rows slide on a sine

Three constants, three separate ideas, and each one can be changed on its own: 0.06 is the spatial frequency (how many ripples fit down the frame), 3 is the temporal frequency (how fast they travel), and 12 is the amplitude (how far the rows move). A sine wave taught this way stops being a shape in a textbook and becomes three knobs with a face behind them. The amplitude is the one on the dial — it is MODIFIER in the effect as shipped — so the whole of “what does the amplitude of a sine wave mean” is one drag, on your own face, while the wave keeps travelling.

What it teaches about programming, not just maths

Four lessons come free, and they are the ones that are hard to stage on purpose:

A 45-minute plan that works

  1. 0–5 min. Open /camera/transform, press Start camera (on a machine that has allowed the camera before, the picture is already there), and walk the whole effect row with the left and right arrow keys (on a tablet or a phone, swipe across the picture instead). No code yet — just the claim that all twenty-one of these are on the screen in front of them.
  2. 5–10 min. The address formula, i = (y * width + x) * 4, on the board. One diagram of a 4×3 grid with the indices written in.
  3. 10–20 min. Invert, then brightness. Discover clamping by hitting it (Lesson 1).
  4. 20–30 min. Black & white and the weights (Lesson 2). Ask why green wins before telling them.
  5. 30–40 min. Pixelate, with MODIFIER as the single knob — literally, on the picture (Lesson 3). Hold a vote on the block size at which a face is no longer a person, and settle it by dragging the dial.
  6. 40–45 min. Everyone records a five-second clip of their own best effect. It stays in the tab; downloading it is their choice.
Two practical notes. The browser will ask each machine for camera permission, and nothing starts until someone says yes. After that yes the tab opens straight into the picture, which is worth knowing before thirty cameras come on at once at the start of the second lesson. And a laptop that hands you 640×480 after you asked for 1080p is not broken; that gap has a write-up of its own, and makes a good extension question.

Timelapse: the same tab as a measuring instrument

Record has a second mode. Timelapse keeps one frame every interval you choose — from a fifth of a second to thirty seconds — and then replays them at 30 fps, so an hour of real time becomes a clip you can watch. The compression factor is arithmetic a class can do out loud: at one frame a second played back at thirty, the film runs 30× faster than the room did; at one frame every ten seconds, 300×.

That turns the photo booth into a laboratory instrument for anything too slow to watch:

That last one is not a gimmick. The effect is applied before anything is recorded, so what lands in the file is the transformed picture and never the camera frame behind it. A pixelated timelapse of a classroom is a recording that was never identifiable in the first place.

Try it

Open the photo booth → Twenty-one effects, all of them editable source. Arrow keys — or a swipe across the picture — walk the row; f is fullscreen, r records, ⌘/Ctrl+Enter applies your changes. See what your camera reports → Asked, supported, got — the three answers a browser gives about a webcam, with the disagreements flagged. Open a clip you recorded → The atom tree of the file the browser just wrote — and the camera metadata it does not contain.

Related reading