From 7f3eb4e5a96a493965de114006369d08a283f090 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 06:19:41 +0000 Subject: [PATCH] Add and publish 'How a Computer Sees' ML lesson New chapter in the ML track (order 22, after Neural Networks) teaching convolution as the core operation of computer vision: - src/lib/ml/computer-vision-data.ts: seeded 14x14 image + pure convolution - src/components/ml/ConvolutionExplorer.tsx: hero interactive (filter + strength) - src/components/ml/ComputerVisionLesson.tsx: prose in the house voice - MlLessonCover.tsx: hand-drawn SVG cover - registered the body, published, and regenerated supabase/chapters.sql (190 chapters) so completions of the lesson are allowlisted for XP Co-authored-by: samanyugoyal2010 --- src/app/learn/ml/(chapters)/[slug]/page.tsx | 2 + src/components/ml/ComputerVisionLesson.tsx | 145 ++++++++++++++++++ src/components/ml/ConvolutionExplorer.tsx | 155 +++++++++++++++++++ src/components/ml/MlLessonCover.tsx | 65 ++++++++ src/lib/ml-lessons.ts | 21 ++- src/lib/ml/computer-vision-data.ts | 157 ++++++++++++++++++++ supabase/chapters.sql | 3 +- 7 files changed, 546 insertions(+), 2 deletions(-) create mode 100644 src/components/ml/ComputerVisionLesson.tsx create mode 100644 src/components/ml/ConvolutionExplorer.tsx create mode 100644 src/lib/ml/computer-vision-data.ts diff --git a/src/app/learn/ml/(chapters)/[slug]/page.tsx b/src/app/learn/ml/(chapters)/[slug]/page.tsx index d0fed55..e5d582c 100644 --- a/src/app/learn/ml/(chapters)/[slug]/page.tsx +++ b/src/app/learn/ml/(chapters)/[slug]/page.tsx @@ -28,6 +28,7 @@ import { FeatureScalingLesson } from "@/components/ml/FeatureScalingLesson"; import { GradientDescentLesson } from "@/components/ml/GradientDescentLesson"; import { RegularisationLesson } from "@/components/ml/RegularisationLesson"; import { NeuralNetworksLesson } from "@/components/ml/NeuralNetworksLesson"; +import { ComputerVisionLesson } from "@/components/ml/ComputerVisionLesson"; import { FromNotebookToProductionLesson } from "@/components/ml/FromNotebookToProductionLesson"; import { LEARN_VIBECODING_HREF } from "@/lib/links"; import { getAdjacent, getChapter, getChapters, getPositionLabel } from "@/lib/learn-nav"; @@ -60,6 +61,7 @@ const ML_LESSON_BODIES: Record React.ReactElement> = { "gradient-descent": GradientDescentLesson, regularisation: RegularisationLesson, "neural-networks": NeuralNetworksLesson, + "computer-vision": ComputerVisionLesson, "from-notebook-to-production": FromNotebookToProductionLesson, }; diff --git a/src/components/ml/ComputerVisionLesson.tsx b/src/components/ml/ComputerVisionLesson.tsx new file mode 100644 index 0000000..9cb6391 --- /dev/null +++ b/src/components/ml/ComputerVisionLesson.tsx @@ -0,0 +1,145 @@ +import { Callout } from "@/components/learn/primitives/Callout"; +import { CompareGrid, TakeawayCard } from "@/components/learn/primitives/Cards"; +import { Lead, LessonSection, P, Strong } from "@/components/learn/primitives/LessonSection"; +import { ConvolutionExplorer } from "@/components/ml/ConvolutionExplorer"; + +export function ComputerVisionLesson() { + return ( +
+ + You have heard that a neural network can recognise a cat. It cannot see one. What arrives at + the network is a grid of brightness values, and what it learns is a stack of tiny filters + that each react to one kind of edge. This lesson is that filter, made concrete: nine numbers + and a little arithmetic, slid over a picture until something appears. + + + +

+ Open any photo far enough and it stops being a photo. It is a rectangle of pixels, and + each pixel is a number: how bright that spot is, from 0 for black to 1 for white. A small + grey square is 0.4. Its neighbour is 0.41. There is no + “cat” anywhere in the file — only a few hundred thousand of these values laid + out in a grid. +

+

+ Colour changes nothing important. A colour image is three of these grids stacked — one + for red, one for green, one for blue — and everything below works the same on each. The + picture on the right is a single 14×14 grid, drawn so you can read the numbers off + it: the disc is a patch of high values, the dark background is low ones. +

+

+ So the question of vision is not “what is in the picture”. It is a narrower, + answerable one: what can you compute from a grid of numbers that tells + you a corner is here, or a stroke of fur runs there? +

+
+ + +

+ Here is the whole operation. Take a small square of weights — three by three, so nine + numbers. Lay it over one pixel and its eight neighbours. Multiply each pixel by the weight + sitting on top of it, add the nine products up, and write the total into that pixel’s + spot in a new grid. That is a convolution, and it is the only arithmetic + in this entire field that you cannot skip. +

+

+ The nine weights are the filter, and their pattern is everything. Make them all{" "} + one-ninth and each pixel becomes the average of its neighbourhood: the + picture blurs. Make the centre large and its neighbours negative and you get the opposite, + a sharpen. Make them sum to zero and something stranger happens — a flat + region cancels itself out to black, and only the places where brightness changes survive. + That last one is an edge detector, and it is doing arithmetic, not magic. +

+

+ Drag the strength below from nothing to full. At zero you see the original untouched; on + the way up, the filter’s effect fades in, so you can watch exactly what those nine + numbers add and take away. +

+
+ + + + +

+ Notice what the filter is not. It is not a rule about the disc, or about the right-hand + bar. It is nine numbers that know nothing about where they are, and they are applied at{" "} + every position in the grid, unchanged. The edge filter finds the edge of + the disc and the edge of the bar with the same nine weights, because an edge is an edge + wherever it sits. +

+

+ That reuse is the point, and it buys two things at once. An edge detector learned in the + top-left corner works in the bottom-right for free — the network does not have to see a + cat in every position to recognise one that has moved. And it is cheap: a filter is nine + weights whether the image is 14 pixels wide or 4,000. A fully-connected layer over a + megapixel image would need a million weights per unit; a convolution needs nine, slid. +

+ + A pattern learned in one place is recognised everywhere. Nine weights cover an + image of any size. The same edge detector serves the whole picture. + + ), + }, + { + title: "What it assumes", + tone: "caution", + children: ( + <> + That what matters is local and the same everywhere — true for edges and + textures, less true when a pixel’s meaning depends on the far side of the + image. That is what stacking layers is for. + + ), + }, + ]} + /> +
+ + +

+ Every filter you just tried was chosen by hand — someone knew that{" "} + -1 -1 -1 / -1 8 -1 / -1 -1 -1 finds edges. A convolutional network throws + that knowledge away. It starts with random weights in each filter and lets gradient descent + adjust them, exactly as in the previous lesson, until the filters that help it name the + picture are the ones that survive. Nobody tells it to look for edges. It discovers that + edges are worth looking for. +

+

+ And it does not stop at one layer. Feed the edge grid into another convolution and its + filters combine edges into corners and curves; feed that forward again and later filters + respond to eyes, wheels, letters. This is the neural network you already met, with one + structural idea added: the same small filter, slid everywhere, stacked. + The depth builds meaning; the sliding makes it affordable. +

+ + The nine weights are usually joined by a tenth, a bias added to every sum, and + the result is passed through the same bend — the activation — that gave a plain network + its curve. The convolution is what makes it a network for images; everything else + is the machinery you have already seen. + +
+ + +
+ ); +} diff --git a/src/components/ml/ConvolutionExplorer.tsx b/src/components/ml/ConvolutionExplorer.tsx new file mode 100644 index 0000000..c5b792a --- /dev/null +++ b/src/components/ml/ConvolutionExplorer.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useId, useState } from "react"; +import { SegmentedControl } from "@/components/learn/primitives/SegmentedControl"; +import { formatPercent } from "@/lib/ml/format"; +import { GRID, IMAGE, KERNELS, filteredImage } from "@/lib/ml/computer-vision-data"; + +const CELL = 18; +const DIM = GRID * CELL; + +/** Brightness 0..1 to a warm ink-on-paper ramp. Deterministic by construction. */ +function pixelFill(value: number): string { + const dark = [35, 32, 27]; + const light = [251, 246, 236]; + const r = Math.round(dark[0] + (light[0] - dark[0]) * value); + const g = Math.round(dark[1] + (light[1] - dark[1]) * value); + const b = Math.round(dark[2] + (light[2] - dark[2]) * value); + return `rgb(${r} ${g} ${b})`; +} + +/** Integers print bare; fractions (the blur's 1/9) to two places. No locale. */ +function formatWeight(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(2); +} + +function PixelGrid({ pixels, label }: { pixels: readonly (readonly number[])[]; label: string }) { + return ( + + {pixels.map((row, y) => + row.map((value, x) => ( + + )), + )} + + + ); +} + +export function ConvolutionExplorer() { + const [kernelId, setKernelId] = useState("edges"); + const [strength, setStrength] = useState(1); + const sliderId = useId(); + + const kernel = KERNELS.find((k) => k.id === kernelId) ?? KERNELS[0]; + const output = filteredImage(kernelId, strength); + + return ( +
+
+ Slide a filter across the image +
+ +

+ The picture on the left is a grid of brightness values — nothing more.{" "} + Pick a filter, then{" "} + drag the strength from nothing to full and watch + the same nine numbers, slid over every pixel, redraw it on the right. +

+ +
+
+

Original

+
+ +
+
+
+

+ {kernel.label === "None" ? "Filtered (no filter)" : `${kernel.label} filter`} +

+
+ +
+
+
+ +
+ {/* The kernel itself: nine numbers, centre pixel picked out. */} +
+

+ The filter (3×3) +

+
+ {kernel.weights.map((weight, index) => ( + + {formatWeight(weight)} + + ))} +
+
+ +
+ ({ value: k.id, label: k.label }))} + value={kernelId} + onValueChange={setKernelId} + /> + +
+ + + {formatPercent(strength)} + +
+ setStrength(Number(event.target.value))} + className="mt-1 w-full accent-learn-accent" + /> + +

{kernel.note}

+
+
+
+ ); +} diff --git a/src/components/ml/MlLessonCover.tsx b/src/components/ml/MlLessonCover.tsx index 2542a95..5595533 100644 --- a/src/components/ml/MlLessonCover.tsx +++ b/src/components/ml/MlLessonCover.tsx @@ -802,6 +802,70 @@ function DriftCover() { ); } +function VisionCover() { + /* Left: a coarse pixel grid with a bright disc, and the 3x3 filter window + laid over its edge. Right: what an edge filter leaves — only the boundary. + Brightness is carried by opacity on a single ink, so it reads at 160px. */ + const N = 5; + const cell = 10.5; + const gap = 1.2; + const leftX = 12; + const rightX = 96; + const top = 20; + const centre = 2; + const cells = Array.from({ length: N }, (_, j) => + Array.from({ length: N }, (_, i) => ({ i, j })), + ).flat(); + const dist = (i: number, j: number) => Math.hypot(i - centre, j - centre); + return ( + + {cells.map(({ i, j }) => ( + + ))} + + + {cells.map(({ i, j }) => { + const d = dist(i, j); + return ( + = 1.2 && d < 2.4 ? 0.95 : 0.08} + /> + ); + })} + + ); +} + const COVERS: Record React.ReactElement> = { "what-is-ml": RulesCover, "features-and-labels": FeaturesCover, @@ -824,6 +888,7 @@ const COVERS: Record React.ReactElement> = { "gradient-descent": DescentCover, regularisation: PenaltyCover, "neural-networks": NetworkCover, + "computer-vision": VisionCover, "from-notebook-to-production": DriftCover, }; diff --git a/src/lib/ml-lessons.ts b/src/lib/ml-lessons.ts index 055787c..bbc8fbf 100644 --- a/src/lib/ml-lessons.ts +++ b/src/lib/ml-lessons.ts @@ -463,9 +463,28 @@ export const ML_CHAPTERS: readonly LearnChapter[] = [ status: "published", }, { - slug: "from-notebook-to-production", + slug: "computer-vision", partId: "how-fitting-works", order: 22, + title: "How a Computer Sees", + description: + "A network that recognises a cat never sees a cat — it sees a grid of brightness values, and a stack of tiny filters that each fire on one kind of edge. Slide a three-by-three filter across a real image and watch edges, blur and texture fall out of nine numbers.", + level: "advanced", + minutes: 13, + prerequisites: ["features-and-labels"], + tags: ["Models", "Interactive"], + headings: [ + { id: "an-image-is-a-grid-of-numbers", text: "An image is a grid of numbers", level: 2 }, + { id: "a-filter-is-nine-numbers-and-some-arithmetic", text: "A filter is nine numbers and some arithmetic", level: 2 }, + { id: "the-same-window-slid-everywhere", text: "The same window, slid everywhere", level: 2 }, + { id: "what-the-network-learns-for-itself", text: "What the network learns for itself", level: 2 }, + ], + status: "published", + }, + { + slug: "from-notebook-to-production", + partId: "how-fitting-works", + order: 23, title: "From Notebook to Production", description: "A model that scored well on Tuesday's data is not a system, and the gap between the two is where most projects quietly die. Age a deployed model month by month and watch the score decay before anyone files a bug.", diff --git a/src/lib/ml/computer-vision-data.ts b/src/lib/ml/computer-vision-data.ts new file mode 100644 index 0000000..4a83d74 --- /dev/null +++ b/src/lib/ml/computer-vision-data.ts @@ -0,0 +1,157 @@ +/** + * Seeded data for the "How a Computer Sees" lesson. + * + * The image is a small grayscale grid built once at module scope from a fixed + * seed, so the server and the browser render the byte-identical picture — no + * hydration mismatch is possible. Everything downstream (the convolution) is + * pure arithmetic over that grid, so it too is deterministic. + * + * No Math.random, no Date: see @/lib/ml/random for why that matters here. + */ + +import { mulberry32 } from "@/lib/ml/random"; + +/** The picture is a GRID x GRID square of brightness values in [0, 1]. */ +export const GRID = 14; + +/** + * A hand-built little scene: a bright disc, a softer bar, and a diagonal + * streak on a gently graded background, with a whisper of noise on top. The + * shapes give hard edges for an edge filter to find; the noise gives a blur + * something to smooth. `IMAGE[y][x]` is row-major, brightness 0 (ink) to 1 + * (paper). + */ +function buildImage(): number[][] { + const random = mulberry32(20260822); + const rows: number[][] = []; + + for (let y = 0; y < GRID; y += 1) { + const row: number[] = []; + for (let x = 0; x < GRID; x += 1) { + // A subtle top-to-bottom gradient so no region is perfectly flat. + let value = 0.18 + 0.14 * (y / (GRID - 1)); + + // Bright disc, upper-left of centre. + const dx = x - 4.4; + const dy = y - 5.2; + if (Math.sqrt(dx * dx + dy * dy) < 3.1) value = 0.86; + + // A mid-tone bar on the right. + if (x >= 9 && x <= 12 && y >= 2 && y <= 7) value = 0.55; + + // A bright anti-diagonal streak across the lower half. + if (Math.abs(x - (GRID - 1 - y)) <= 1 && y > 6) value = 0.9; + + // One noise draw per pixel — fixed call order keeps the picture stable. + value += (random() - 0.5) * 0.1; + + row.push(Math.max(0, Math.min(1, value))); + } + rows.push(row); + } + + return rows; +} + +export const IMAGE: readonly (readonly number[])[] = buildImage(); + +/** + * How a kernel's raw output becomes a brightness to draw. + * - "direct" clamps the weighted sum straight into [0, 1]. + * - "magnitude" takes its absolute value first, so a zero-sum edge filter + * turns flat regions to black and any change of brightness to light. + */ +export type KernelMode = "direct" | "magnitude"; + +export interface Kernel { + id: string; + label: string; + /** 3x3, row-major. */ + weights: readonly number[]; + mode: KernelMode; + /** One sentence, house voice: what this filter does and why. */ + note: string; +} + +const BLUR = 1 / 9; + +export const KERNELS: readonly Kernel[] = [ + { + id: "identity", + label: "None", + weights: [0, 0, 0, 0, 1, 0, 0, 0, 0], + mode: "direct", + note: "The do-nothing filter. It copies the centre pixel and ignores its neighbours, so the output is the input.", + }, + { + id: "blur", + label: "Blur", + weights: [BLUR, BLUR, BLUR, BLUR, BLUR, BLUR, BLUR, BLUR, BLUR], + mode: "direct", + note: "Every pixel becomes the average of its nine. Noise averages away, but so does real detail — softness costs sharpness.", + }, + { + id: "sharpen", + label: "Sharpen", + weights: [0, -1, 0, -1, 5, -1, 0, -1, 0], + mode: "direct", + note: "The opposite bargain: push the centre up and its neighbours down, and every edge gets crisper — along with every speck of noise.", + }, + { + id: "edges", + label: "Edges", + weights: [-1, -1, -1, -1, 8, -1, -1, -1, -1], + mode: "magnitude", + note: "The nine weights sum to zero, so a flat patch cancels to black. Only where brightness changes does anything survive — the filter finds edges and nothing else.", + }, + { + id: "emboss", + label: "Emboss", + weights: [-2, -1, 0, -1, 1, 1, 0, 1, 2], + mode: "direct", + note: "A lopsided edge filter. Because the weights lean top-left to bottom-right, edges facing that way light up and the picture looks lit from one corner.", + }, +]; + +function clampIndex(value: number, size: number): number { + if (value < 0) return 0; + if (value >= size) return size - 1; + return value; +} + +/** The weighted sum of the 3x3 neighbourhood around (x, y), edges replicated. */ +function convolveAt(x: number, y: number, weights: readonly number[]): number { + let sum = 0; + for (let ky = -1; ky <= 1; ky += 1) { + for (let kx = -1; kx <= 1; kx += 1) { + const sy = clampIndex(y + ky, GRID); + const sx = clampIndex(x + kx, GRID); + sum += IMAGE[sy][sx] * weights[(ky + 1) * 3 + (kx + 1)]; + } + } + return sum; +} + +/** + * Apply a kernel to the whole image at a given strength, and return the + * brightness grid to draw. `strength` blends the filtered pixel against the + * original, so 0 is the untouched image and 1 is the filter at full effect — + * that is the value the slider drives. + */ +export function filteredImage(kernelId: string, strength: number): number[][] { + const kernel = KERNELS.find((k) => k.id === kernelId) ?? KERNELS[0]; + const out: number[][] = []; + + for (let y = 0; y < GRID; y += 1) { + const row: number[] = []; + for (let x = 0; x < GRID; x += 1) { + const raw = convolveAt(x, y, kernel.weights); + const processed = kernel.mode === "magnitude" ? Math.abs(raw) : raw; + const blended = IMAGE[y][x] * (1 - strength) + processed * strength; + row.push(Math.max(0, Math.min(1, blended))); + } + out.push(row); + } + + return out; +} diff --git a/supabase/chapters.sql b/supabase/chapters.sql index 09896fa..b8e7bfb 100644 --- a/supabase/chapters.sql +++ b/supabase/chapters.sql @@ -10,7 +10,7 @@ -- `progress` rows for it in place (they are already written) but stops new -- ones being accepted. -- --- 189 published chapters across 8 tracks. +-- 190 published chapters across 8 tracks. begin; @@ -108,6 +108,7 @@ insert into public.chapters (course_id, chapter_slug) values ('ml', 'gradient-descent'), ('ml', 'regularisation'), ('ml', 'neural-networks'), + ('ml', 'computer-vision'), ('ml', 'from-notebook-to-production'), ('financial-literacy', 'why-money-rules-matter'), ('financial-literacy', 'income-and-expenses'),