EVERYTHING AIAI engineering, made visual
0/28 complete
LESSON 01 · COMPUTER VISION × BUILD

An image is a tensor
of light samples.

A sensor counts photons on a grid and rounds each count to an integer. Everything a vision model ever does — convolution, detection, segmentation, generation — starts from that grid of numbers and the encoding contract around it. Get the contract wrong and the model still runs; it is simply wrong.

45 MIN · 6 CHAPTERS + CHECKPREREQ · PHASE 1 · LESSON 12 · PHASE 3 · LESSON 11
FIG. 01 / A SENSOR SAMPLES THE LIGHT
scene sampled & quantized HWC memory
LESSON 01TYPE · BUILD~45 MINPREREQ · PHASE 1 · LESSON 12 · PHASE 3 · LESSON 11ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the problem ↓
01 / A PIXEL IS A MEASUREMENT

Sampling sets where. Quantization sets how finely.

A detector integrates light and the voltage is bucketed into an integer. Resolution decides how many measurements cover the scene; bit depth decides how many levels each one can land on. Both are irreversible, and together they cap every model downstream — no architecture invents detail that was never sampled or levels that were never recorded.

8-bit = 256 levels · 1920×1080×3 = 6,220,800 values
02 / AN IMAGE IS AN ARRAY

Three questions: shape, dtype, order.

Decoded images arrive as uint8 arrays with the channel axis last — HWC (height, width, channels) — because that is how scanlines leave a sensor. PyTorch wants CHW (channels, height, width), because convolution kernels slide across two contiguous spatial planes. A transpose moves addresses, not values, so getting it wrong produces a valid tensor with the wrong meaning.

PIL (H, W, 3) → transpose(2, 0, 1) → PyTorch (3, H, W)
03 / THE CONTRACT

Size, dtype, range, layout, distribution.

A pretrained model expects 224×224, float32, [0, 1], per-channel ImageNet statistics, and NCHW. Each step is legal on its own, so a wrong order still runs: standardizing raw uint8 pixels makes your input 619× too large, and forgetting ÷255 is the single most common silent failure in applied vision.

mean [0.485, 0.456, 0.406] · std [0.229, 0.224, 0.225]
MENTAL MODEL IN ONE SENTENCE

An image is a grid of measurements plus an encoding contract: how many samples (resolution), how finely each is recorded (bit depth), how many channels, in which axis order, on which numeric scale — and every vision model you meet will assume a different set of defaults, so the job is to say them out loud before you train.

By the end you will be able to read any decoded image as shape, dtype and order; move between HWC and CHW on purpose; explain why luma uses 0.299 / 0.587 / 0.114 and YCbCr exists at all; run the full ImageNet contract (resize shorter side 256 → 224 crop → ÷255 → −mean ÷std → CHW) and reverse it; be precise about bit depth and pixel counts (256 levels, 6,220,800 values for 1080p, 150,528 for 224×224); and name the four silent bugs — aliasing, BGR, uint8 wrap, and a step in the wrong order — before they cost you a training run.

THE SILENT INPUT

The model was wrong
before it saw the picture.

Pass a uint8 image where float32 is expected and the network still runs. Feed BGR to a network trained on RGB and accuracy falls off a cliff. Hand it channels-last when it wants channels-first and the first convolution sees the wrong axis. None of these raise a helpful error — they just ruin your metrics.

Every paper you read, every pretrained weight you download and every vision API you call assumes a specific encoding of the input. The architecture is the photogenic part; the input contract is where the expensive bugs live. The source estimates that four out of five silent failures in an applied vision pipeline come from two steps: the missing standardization and the wrong layout — and the first is invisible, because an unstandardized image looks completely normal in a debugger.

What makes this different from ordinary debugging is that the mistake is a perfectly valid tensor. A uint8 array of shape (224, 224, 3) is a legal array. A BGR image is a legal image. The model accepts the numbers, the convolution multiplies them, the activations light up, and the prediction is quietly worse than chance. Nothing crashes — you lose a week.

the whole pipeline, and where it breaks JPEG/PNG file │ decode ← BGR vs RGB lives here (OpenCV) ▼ uint8 · HWC · [0, 255] ← raw bytes, no dtype │ resize (shorter side → model size) │ center crop │ ÷ 255 ← SILENT FAILURE #1 if missing ▼ float32 · HWC · [0.0, 1.0] │ − mean, ÷ std (per channel, ImageNet stats) │ transpose HWC → CHW ← SILENT FAILURE #2 if missing │ add batch axis ▼ float32 · NCHW · ≈ [−2.1, +2.6] → model

This lesson is the foundation the rest of Phase 4 stands on. By the end you will know what a pixel really is, why there are three numbers per pixel instead of one, what “normalize with ImageNet stats” actually does to 210, and how to move between the two layouts every other lesson assumes. Those are small skills, and they are the difference between a model that works in a notebook and one that works in production.

Quick check

A file is decoded into an array of shape (224, 224, 3) with dtype uint8. What does one number in that array represent?

LIGHT BECOMES NUMBERS

A pixel is a measurement,
not a coloured square.

A camera sensor counts photons on a grid of tiny detectors. Each detector integrates light for a fraction of a second, emits a voltage, and that voltage is bucketed into an integer. One detector becomes one pixel — a number with a position, not a tile with an area.

Two decisions happen at the sensor, and together they set the ceiling on every model that follows. Spatial sampling decides how many detectors cover the scene: too few and fine detail folds into patterns that were never there (aliasing); too many and storage and compute explode. Intensity quantization decides how finely each measurement is bucketed: 8 bits per channel is the display standard, while 10, 12 and 16 bits exist for medical, HDR (high dynamic range) and raw sensor pipelines.

8-bit → 2^8 = 256 levels per channel ← the standard 10-bit → 2^10 = 1,024 levels ← HDR, phone ProRAW 12-bit → 2^12 = 4,096 levels ← medical, scientific 16-bit → 2^16 = 65,536 levels ← raw and TIFF pipelines 1920 × 1080 × 3 = 6,220,800 values ← one 1080p RGB frame = 6.22 MB as uint8 (1 byte per value) = 24.88 MB as float32 (4 bytes per value) 224 × 224 × 3 = 150,528 values ← what one ImageNet model sees = 588 KiB as float32 (a 41× smaller tensor) 4× fewer samples per axis → 1/16 the pixels 1920 × 1080 = 2,073,600 pixels → 480 × 270 = 129,600 pixels

The dtype matters as much as the count. A float32 tensor is four times the memory of the same uint8 image, which is why 1080p training sets are decoded to uint8 on disk and converted to float one batch at a time. And notice what quantization can never do: a 4-bit image with 16 levels cannot be recovered into a 16-bit one — the levels that were never recorded are gone, exactly like the detail that was never sampled.

The same arithmetic applies to a video stream and is worth internalizing: 30 frames per second of 1080p RGB uint8 is 6.22 MB × 30 = 186.6 MB per second of raw data, which is why codecs exist, why video models downsample aggressively, and why the sampling decision you make at ingest is a budget decision, not a detail.

The pixel-grid sampler

Two choices decide everything downstream: how many samples you take (resolution) and how finely you measure each one (bit depth). Slide them down and watch the fine-detail scene turn into rings that are not in it — that is aliasing, and no model can undo it.

samples per axis 16 × 16 values (H × W × 3) 768 levels per channel 2^8 = 256 ← the 8-bit standard size at this grid 768 B uint8 · 3,072 B float32 pattern 14 rings centre → corner samples along radius 8 (Nyquist needs ≥ 28) verdict ALIASED · ≈ 2 broad rings appear that are not in the scene reference frame 1920 × 1080 × 3 = 6,220,800 values = 6.22 MB uint8 · 24.88 MB float32 4× coarser grid 4 × 4 = 48 values — 1/16 of the pixels

Bit depth is a per-channel budget: 8 bits gives 256 levels, so 8-bit RGB spends 24 bits per pixel. This demo quantizes one brightness field, which is the same idea with one third of the arithmetic.

Where the aliasing numbers come from (Nyquist, in one paragraph)

A pattern needs at least two samples per cycle to be represented at all. Sample it more coarsely and the pattern does not disappear — it reappears at a lower frequency, which is why a fine grid of rings can look like a few broad rings that are not in the scene. The frequency it folds to is |f − n·fs| for the nearest integer n, where f is the pattern’s frequency and fs the sampling rate.

lab example: 14 rings from centre to corner (f = 14 cycles per radius) samples along a radius at 16×16 = 8 → Nyquist limit 8/2 = 4 cycles 14 > 4, so it folds: n = round(14 / 8) = 2 → |14 − 2×8| = 2 you see 2 broad rings; the scene contains 14 fine ones samples along a radius at 64×64 = 32 → limit 16 cycles 14 ≤ 16 → resolved, every ring is real

Teaching model, labelled as such: the patterns in this lesson are analytic rings, so the frequencies are exact and the arithmetic is checkable. Real scenes have energy at every frequency, and every camera applies an optical low-pass filter (the anti-alias filter) before sampling for exactly this reason.

READING THE TENSOR

Three questions for any array:
shape, dtype, order.

Once a decoder hands you pixels, an image is an array with a shape, a dtype and an axis order. Answer those three questions before you write a single model line — every library will silently assume you did.

NumPy has no image type. What you get from a decoder is an array whose last axis is the channel axis and whose dtype is almost always uint8. The first three things to print are arr.shape, arr.dtype and arr[0, 0] — the last one is a triple like [210, 140, 30], and looking at it is the fastest way to notice that a “grayscale” image is secretly three identical channels, or that your float image is still in 0–255.

Slicing is how you pull a channel out: arr[:, :, 0] is the red plane, shape (224, 224), dtype uint8. Slices are views, not copies — writing to them writes to the original. The same is true of a transpose: arr.transpose(2, 0, 1) returns a new view with re-arranged strides, so the twelve numbers of a 2×2 image are re-addressed rather than moved. That is why a transpose is free, and why arr.transpose(2, 0, 1).copy() exists when you actually need contiguous memory.

read, slice, transpose — the first ten minutes of any vision projectpython
import numpy as np

arr = np.load("frame.npy")        # or np.asarray(Image.open("frame.png"))

print(arr.shape)                  # (H, W, 3) or (H, W, 4) — RGBA is 4 channels
print(arr.dtype)                  # uint8, almost always
print(arr[0, 0])                  # [210 140  30] — one pixel, three samples
print(f"range: [{arr.min()}, {arr.max()}]")

R = arr[:, :, 0]                  # a view of the red plane, shape (H, W)
G = arr[:, :, 1]
B = arr[:, :, 2]
print(R.mean(), G.mean(), B.mean())   # per-channel means: the pipeline's alarm sensors

# HWC -> CHW for PyTorch. Free: it re-addresses, it does not copy.
arr_chw = arr.transpose(2, 0, 1)
print(arr.shape, "->", arr_chw.shape)     # (H, W, 3) -> (3, H, W)
print(arr_chw.flags["C_CONTIGUOUS"])      # False — the view has new strides
# and back
arr_hwc = arr_chw.transpose(1, 2, 0)
assert arr_hwc.shape == arr.shape
Run this on every new dataset before training. Per-channel means that differ wildly from the [0,1] band, or a shape whose last axis is not 3 or 4, tell you the pipeline is wrong before the first gradient step.

PyTorch adds one step to the same convention: permute for an existing tensor, unsqueeze(0) for the batch axis, and a .contiguous() after the permutation if a layer or a view complains. The reason CHW exists is mechanical rather than mystical: a convolution kernel slides across height and width, so keeping each channel as one contiguous plane lets the kernel read memory in order. Disk formats keep HWC because that matches the order scanlines leave the sensor.

The transpose board

Twelve values, two orderings. Click any pixel or memory slot to see where the same three numbers live under each layout. Nothing is copied by a transpose — only the address arithmetic changes.

HWC · INTERLEAVED PIXELS
image · 2 × 2 × 3
Colours are the pixel values themselves — the array is the picture.
memory · (2, 2, 3) → 12 values in this order
flat memory · 12 values
layout HWC · (height, width, channels) shape (2, 2, 3) → 12 values, unchanged flat order R00 G00 B00 | R01 G01 B01 | R10 G10 B10 | R11 G11 B11 selected pixel (h0, w1) = (60, 150, 90) R = 60 slot flat index 3 of 11 (memory order starts at 0) convert chw = hwc.transpose(2, 0, 1) # numpy · .permute(2, 0, 1) # torch expects PIL, OpenCV, matplotlib, JPEG/PNG decoders — scanlines come off the sensor this way

Feed (1, 2, 2, 3) to Conv2d(in_channels=3) and PyTorch reads dim 1 as the channel axis: 2 channels where the weights expect 3 → RuntimeError. A layout mistake in PyTorch is loud; a colour-space mistake is silent.

Quick check

You load a 480×640 image with Pillow, then pass a tensor of shape (1, 480, 640, 3) into Conv2d(in_channels=3). What happens?

COLOR SPACES

RGB is how light arrives.
It is not always how you should think.

A colour space is a coordinate system for the same physical light. RGB suits capture and display; luma suits perception; HSV suits “find the orange things”; YCbCr suits compression. Choose the one where your operation is simple.

To get colour, a sensor covers its grid with a mosaic of red, green and blue filters. After demosaicing, every spatial location has three integers — the responses of the neighbouring red-, green- and blue-filtered detectors. Three is a convention, not a law: depth cameras add a Z channel, satellites add infrared and ultraviolet bands, X-rays have one channel, hyperspectral images have hundreds. The channel count is just the last axis, and convolution layers learn to mix across it.

The first alternative space is the oldest and most useful: grayscale. People say “average the channels”, but the conversion is a weighted sum, because the eye is most sensitive to green and least to blue. The classic ITU-R BT.601 weights are used by OpenCV, scikit-image and the source’s own implementation:

Y = 0.299 R + 0.587 G + 0.114 B (ITU-R BT.601) worked on the lesson's pixel (210, 140, 30): 0.299 × 210 = 62.79 0.587 × 140 = 82.18 0.114 × 30 = 3.42 ------ luma = 148.39 → 148 in uint8 flat average = (210 + 140 + 30) / 3 = 126.67 → 127 the 21.7-level difference is not rounding — a green-heavy pixel must look bright, not merely average

HSV re-coordinates the same cube as hue (an angle), saturation (how far from grey) and value (how bright). Worked on the same pixel: divide by 255 → (0.824, 0.549, 0.118); the largest is red, the smallest is blue, so the hue formula is 60° × (G − B)/delta with delta = 0.824 − 0.118 = 0.706 → 60 × (0.549 − 0.118)/0.706 = 36.7°, saturation = 0.706/0.824 = 0.857, value = 0.824. Hue 36.7° is orange — which is exactly what the pixel looks like. Now “select every orange object” is a one-line threshold on one number instead of a region in three.

YCbCr keeps the luma Y and adds two chroma differences centred on 128:

Y = 0.299 R + 0.587 G + 0.114 B = 148.39 (brightness) Cb = 128 − 0.168736 R − 0.331264 G + 0.5 B = 61.19 (blue−yellow) Cr = 128 + 0.5 R − 0.418688 G − 0.081312 B = 171.94 (red−green) same pixel, three coordinates: (148.39, 61.19, 171.94) round trip back to RGB: (210.00, 140.00, 30.00) — the transform is lossless why compression loves it — JPEG at 4:2:0 chroma subsampling, per 2×2 block of pixels: RGB : 4 pixels × 3 channels = 12 values YCbCr : 4 Y + 1 Cb + 1 Cr = 6 values → 50% smaller

That last line is the whole reason JPEG and most video codecs store YCbCr: the eye resolves brightness detail far better than colour detail, so chroma is stored at half resolution in each direction and nobody notices. Super-resolution and restoration models reuse the trick by working on the Y channel alone and leaving chroma alone — one third of the work for most of the perceived improvement.

One pixel, three coordinate systems

RGB says “how much of each primary”; HSV says “which colour, how saturated, how bright”; YCbCr says “how bright, and how far blue/red from neutral”. Move the sliders and watch all three describe the same pixel — with the luma weights that make grayscale look right.

RGB (210, 140, 30) #d28c1e luma = 0.299×210 + 0.587×140 + 0.114×30 = 62.79 + 82.18 + 3.42 = 148.39 flat average = 126.67 difference 21.7 levels — the weights matter HSV h 36.7° s 0.857 v 0.824 (OpenCV's 8-bit HSV divides h by 2 and s, v by 255) YCbCr Y 148.39 Cb 61.19 Cr 171.94 Cb is 66.8 below neutral (less blue) · Cr is 43.9 above it (more red) round trip back to RGB: max channel error 0.00 compression per 2×2 block: RGB keeps 12 values, 4:2:0 YCbCr keeps 6 — 50% smaller

The wheel is drawn from hsvToRgb and the round trip runs rgbToYcbcr then ycbcrToRgb: a colour space is a coordinate system, not a different image. Compression is where the choice earns money — the eye resolves luma detail far better than chroma detail.

Quick check

Why does grayscale use 0.299 R + 0.587 G + 0.114 B instead of dividing by three?

THE PRETRAINED CONTRACT

A pretrained model is a contract
written in statistics.

Weights trained on ImageNet were trained on inputs with a specific size, dtype, range, layout and distribution. Reproduce that distribution exactly and the weights transfer; change one of them and you are fine-tuning with your eyes closed.

Most ImageNet classifiers expect 224×224; modern detectors expect 384 or 512 on the short side. Your images rarely match, so the first decision is how to resize. The standard recipe resizes the shorter side to 256 and takes a 224×224 centre crop. Worked on a 1920×1080 landscape frame:

scale = 256 / min(1920, 1080) = 256 / 1080 = 0.2370 new size = 1920 × 0.2370 = 455.0 → 455 × 256 centre crop = 224 × 224, keeping the middle: top = (256 − 224) // 2 = 16 left = (455 − 224) // 2 = 115 pixels = 455 × 256 = 116,480 before the crop 224 × 224 = 50,176 after → 43.1% of the resized frame survives; 57% is discarded the alternative recipes, and when they win: resize + pad keeps every pixel, adds bars → detection, OCR resize directly stretches the geometry → cheap classification shorter + crop the ImageNet standard, above → pretrained backbones

The resize itself needs a rule for pixels that land between the old grid: nearest neighbour (fastest, blocky, and the only safe choice for masks and label maps), bilinear (the training default), bicubic (sharper on upscaling), Lanczos (best for assets you will look at). One more flag matters more than the choice: when you downsample, the resize must low-pass the source first, or fine detail aliases. torchvision exposes this as antialias=True; a naive nearest-neighbour decimation is precisely the aliasing machine the sampling chapter measured.

Then the value pipeline. Three conventions dominate, and mixing them up is the source’s “single most common silent failure”:

convention dtype range where you see it raw uint8 [0, 255] files, PIL, OpenCV output normalized float32 [0.0, 1.0] after img.astype("float32") / 255 standardized float32 ≈ [−2.1, +2.6] after (x − mean) / std the walk for one pixel, (R, G, B) = (210, 140, 30), ImageNet statistics mean = [0.485, 0.456, 0.406] std = [0.229, 0.224, 0.225] (computed on the ImageNet training set, on [0, 1] pixels) ÷ 255 (0.8235, 0.5490, 0.1176) − mean (0.3385, 0.0930, −0.2884) ÷ std ( 1.4783, 0.4153, −1.2816) ← what the first conv layer sees for reference: (0, 0, 0) maps to (−2.118, −2.036, −1.804) (1, 1, 1) maps to ( 2.249, 2.429, 2.640) mid grey 128/255 = 0.502 → (0.502 − 0.485) / 0.229 = 0.074 (≈ 0) skip the ÷ 255 and the same red channel becomes (210 − 0.485)/0.229 = 914.91 — 619× the value the model was trained to see

Two honest details. First, the means are not 0.5 and the stds are not 0.25: they are the measured statistics of a particular corpus, which is why green is a little darker than red and blue a little more so. Second, standardizing does not make your image zero-mean — it shifts your image by the training set’s mean. If you fine-tune on a different domain, recompute the statistics on that domain; if you use someone’s pretrained weights, use theirs exactly. Both halves of that sentence are the same rule: the input distribution must match the training distribution.

The normalization calculator

Type a pixel value and watch the three-step walk the source insists on: ÷255 → − mean → ÷ std, then the exact reverse. Toggle “skip ÷255” to see the classic silent bug arrive as a number: the same pixel becomes hundreds of times larger than anything the model was trained on.

THE ARITHMETIC · IMAGENET (RGB)
stepRGB
raw uint821014030
÷ 2550.8235290.5490200.117647
− mean (per channel)0.3385290.093020-0.288353
÷ std (per channel)1.47830.4153-1.2816
reverse → uint821014030
R step 1 ÷ 255 210 / 255 = 0.823529 step 2 − mean 0.823529 − 0.485 = 0.338529 step 3 ÷ std 0.338529 / 0.229 = 1.4783 ← what the conv layer receives reverse × std, + mean, × 255 → 0.8235 × 255 = 210.0 → round 210 WITHOUT ÷ 255 would be (210 − 0.485) / 0.229 = 914.91 — 619× the correct value
WHERE EACH CHANNEL LANDS · TRAINED BAND [-2.12, 2.64]
R 1.478
G 0.415
B -1.282
preset ImageNet (RGB) · the statistics every torchvision pretrained classifier expects mean [0.4850, 0.4560, 0.4060] std [0.2290, 0.2240, 0.2250] trained band [-2.12, 2.64] (a [0,1] image mapped through these stats) pixel (210, 140, 30) ÷255 (0.8235, 0.5490, 0.1176) standardized (1.4783, 0.4153, -1.2816) largest |x| 1.4783 on channel R round trip → (210, 140, 30) exact ✓ bug switch off — this is the contract the model was trained under

The order is the contract: sizes, then dtype, then range, then distribution. Standardizing raw uint8 values skips the range step and multiplies every input by 255 — the model does not complain, it just sees noise-level signal in a saturated first layer.

the contract, in torchvision and in plain torchpython
from torchvision import transforms
from PIL import Image
import torch, torch.nn.functional as F

mean = [0.485, 0.456, 0.406]
std  = [0.229, 0.224, 0.225]

# the idiomatic pipeline: decode, resize shorter side, crop, tensor, standardize
pipeline = transforms.Compose([
    transforms.Resize(256),              # shorter side -> 256
    transforms.CenterCrop(224),          # 224 x 224 from the middle
    transforms.ToTensor(),               # uint8 HWC -> float32 CHW in [0, 1]
    transforms.Normalize(mean=mean, std=std),
])
x = pipeline(Image.open("frame.jpg")).unsqueeze(0)      # (1, 3, 224, 224)
print(x.shape, x.dtype, float(x.min()), float(x.max()))

# the same steps by hand on a batch you already hold
batch_hwc = torch.from_numpy(arr_uint8)                 # (H, W, 3) uint8
batch = batch_hwc.permute(2, 0, 1).unsqueeze(0).float() / 255.0
h, w = batch.shape[-2:]
scale = 256 / min(h, w)
batch = F.interpolate(
    batch,
    size=(round(h * scale), round(w * scale)),
    mode="bilinear",
    align_corners=False,
    antialias=True,               # low-pass before downsampling: no aliasing
)
top, left = (batch.shape[-2] - 224) // 2, (batch.shape[-1] - 224) // 2
batch = batch[:, :, top:top + 224, left:left + 224]
mean_t = torch.tensor(mean).view(1, 3, 1, 1)
std_t = torch.tensor(std).view(1, 3, 1, 1)
batch = (batch - mean_t) / std_t
print(batch.shape)                # (1, 3, 224, 224)
Four steps, one order: resize → crop → ÷255 → standardize → NCHW. The mean/std tensors are reshaped to (1, 3, 1, 1) so they broadcast across the batch and the two spatial axes.
FOUR SILENT BUGS

None of these crash.
All of them cost a week.

Aliasing from a careless resize, BGR where RGB is expected, 8-bit arithmetic that wraps, and a preprocessing step in the wrong order. Each one produces a valid tensor, a running model and a number that is quietly wrong.

1 · Aliasing from naive downsampling. Resizing 1080p to 224×224 keeps only 224²/(1920 × 1080) ≈ 2.4% of the pixels — it throws away ≈98% of them; the honest way to do it is to low-pass first, then sample (box/bilinear with antialias=True). Take every 4.8th pixel instead and detail above the new Nyquist limit folds down into patterns that were never in the scene — the moiré the sampler lab draws at 14 rings and two pixels per cycle. The give-away: the false pattern is stable across frames and moves when the resize factor changes.

2 · BGR where RGB is expected. OpenCV’s cv2.imread returns BGR by historical convention; PIL, matplotlib, torchvision and JPEG all use RGB. The arrays have the same shape and dtype, so nothing complains — but every learned colour filter is now reading the wrong channel. The source’s number for this swap is a ten-point accuracy collapse. Fix: cv2.cvtColor(img, cv2.COLOR_BGR2RGB), and assert it with a test image whose red corner you have looked at with your own eyes.

3 · uint8 arithmetic wraps. A uint8 array is 8-bit integers, so 200 + 100 is not 300:

np.uint8(200) + np.uint8(100) → 44 (300 mod 256) np.uint8(0) − np.uint8(1) → 255 (−1 mod 256) (image * 1.5) on uint8 → values wrap, not saturate the fix, in order of preference: 1. work in float32: arr.astype(np.float32) * 1.5, then clip and cast back 2. use saturating ops: cv2.add(arr, 100) or arr = np.clip(arr + 100, 0, 255) 3. never do read-modify-write on uint8: arr += 100 ← wraps silently

4 · The pipeline in the wrong order. Divide by 255before subtracting the mean (the stats are defined on [0,1] pixels); resize before standardizing (or the interpolation operates on a distribution it was never designed for, and the mean/std you computed no longer describe the pixels you resized); use nearestonly for masks and class IDs, never for photographs.

The professional habit that catches all four is boring and takes thirty seconds: print the shape, dtype, range and per-channel mean at every boundary of the pipeline. A uint8 image that should be float gives a mean of 120.4 instead of 0.472; a BGR swap barely moves the mean at all, which is why you also look at one pixel’s triple and one saved thumbnail. The source’s prompt for this lesson is precisely that audit, written down as a checklist a teammate can run.

The preprocessing pipeline stepper

Six stages, one image, exactly the order the source insists on. Step through and watch shape, dtype and range change while the picture stays recognisable — then plot stage 4 without de-standardizing and see what a normalized array looks like when you forget the inverse.

stage 1 · DECODE shape 64 × 64 × 3 dtype uint8 resize rule nearest decimation — step 2 keeps every second pixel and folds the 14-ring pattern into broad bands the contract, in order 1. decode the file → uint8 HWC 2. resize the shorter side → the model's input size 3. divide by 255 → float32 [0, 1] 4. subtract mean, divide by std → standardized 5. transpose HWC → CHW → (3, H, W) 6. add the batch axis → (1, 3, H, W) ImageNet statistics mean [0.485, 0.456, 0.406] · std [0.229, 0.224, 0.225] computed on [0, 1] pixels — apply them after ÷255, never before

Reversing two steps is the mistake the source warns about: a standardized value plotted as if it were an image comes out black, and an image standardized before ÷255 comes out 619× too large. The lab works at 64 → 32 pixels so the numbers fit on screen; a production pipeline is the same arithmetic at 1080p → 256 → 224.

Quick check

You resize a segmentation mask whose values are class IDs 0–20 from 500×500 down to 224×224. Which interpolation is correct?

CHECK YOURSELF

Five questions.
Then the terms worth keeping.

Answer before you look. The layout question and the mask question are the two that separate a pipeline you copied from a pipeline you can debug at 2 a.m.

0 / 5 answered · 0 correct

01A file on disk is decoded into a NumPy array with shape (224, 224, 3) and dtype uint8. What does each number represent?

02You load an image with Pillow and get an array of shape (480, 640, 3). You pass it as a batched tensor (1, 480, 640, 3) to a PyTorch Conv2d with in_channels=3. What happens?

03Why do ImageNet pretrained models expect inputs standardized with mean=[0.485, 0.456, 0.406] and std=[0.229, 0.224, 0.225]?

04You resize a segmentation mask (integer class IDs 0..20) from 500x500 to 224x224. Which interpolation method is correct?

05RGB grayscale conversion uses weights 0.299 R + 0.587 G + 0.114 B rather than 0.333 R + 0.333 G + 0.333 B. Why?

Key terms, demystified

Click a card to swap the lazy description for what it actually means — with the number that makes each one checkable.

Exercises from the lesson

Three problems with exact arithmetic: a 2×2 round trip that proves a transpose moves addresses and not values, a standardize/destandardize pair that has to survive the uint8 round trip, and a frozen 1×1 convolution that has to reproduce luma exactly. Try first; a worked answer is one click away.

  1. Create a 2×2 RGB uint8 array with four distinct colours (the source's easy exercise). Convert HWC to CHW and back, print both shapes, and prove the round trip preserves every value.
    Show one worked answer

    Build it by hand so the memory order is visible: ```python import numpy as np img = np.array([ [[255, 0, 0], [ 0, 255, 0]], # row 0: red, green [[ 0, 0, 255], [255, 255, 0]], # row 1: blue, yellow ], dtype=np.uint8) print(img.shape) # (2, 2, 3) -> HWC chw = img.transpose(2, 0, 1) print(chw.shape) # (3, 2, 2) -> CHW back = chw.transpose(1, 2, 0) print(np.array_equal(back, img)) # True ``` Why the shapes change the way they do: HWC reads the axes as (height, width, channel), so transpose(2, 0, 1) means "channel becomes axis 0, height becomes axis 1, width becomes axis 2" → (3, 2, 2). The values never move — transpose returns a view with new strides, so no copy is made and the twelve numbers r=255,0,0,0 g=0,255,0,255 b=0,0,255,0 are simply re-indexed. Memory order makes that concrete: in HWC the flat order is R G B | R G B | R G B | R G B (four interleaved triples); in CHW it is R R R R | G G G G | B B B B (three planes). Proof of preservation: compare the flattened arrays, `np.array_equal(img.ravel(), back.ravel())` is also True because the same twelve values come back in the same logical positions. If instead you wrote `back = chw.transpose(2, 0, 1)` you would silently get shape (2, 2, 3) with the channel axis mislabelled — the classic "it runs but the red plane is now the height" bug.

  2. Write `standardize(img, mean, std)` and its inverse so that a round trip gives roundtrip_max_diff ≤ 1 on any uint8 image, and so the same call works on a single HWC image and on a batched NCHW tensor.
    Show one worked answer

    The trick is to standardize over the last axis for HWC and over axis 1 for NCHW, then let broadcasting do the rest: ```python import numpy as np def standardize(img, mean, std, layout="hwc"): x = img.astype(np.float32) / 255.0 shape = (1, 1, 3) if layout == "hwc" else (1, 3, 1, 1) return (x - np.asarray(mean, np.float32).reshape(shape)) / np.asarray(std, np.float32).reshape(shape) def destandardize(x, mean, std, layout="hwc"): shape = (1, 1, 3) if layout == "hwc" else (1, 3, 1, 1) x = x * np.asarray(std, np.float32).reshape(shape) + np.asarray(mean, np.float32).reshape(shape) return np.clip(np.rint(x * 255.0), 0, 255).astype(np.uint8) ``` Worked pixel, channel by channel, with mean = [0.485, 0.456, 0.406] and std = [0.229, 0.224, 0.225]: ``` (210,140,30) ÷255 (0.8235, 0.5490, 0.1176) −mean (0.3385, 0.0930, −0.2884) ÷std ( 1.4783, 0.4153, −1.2816) ×std +mean (0.8235, 0.5490, 0.1176) ×255, rint (210, 140, 30) diff 0 ``` Where the ≤ 1 comes from: a standardized step of 1 uint8 level is (1/255)/0.229 = 0.0171 for red, and the inverse multiplies it back exactly, so the only error source is `rint` on a float that should be an integer — at worst ±0.5 level, i.e. diff ≤ 1. A channel with a very small std would amplify the rounding, which is why you standardize with real per-channel statistics rather than a hand-picked 0.001. Broadcast shapes: HWC uses (1, 1, 3) against (H, W, 3); NCHW uses (1, 3, 1, 1) against (N, 3, H, W) — the same fourteen lines serve both, which is exactly why torchvision's Normalize takes one mean/std vector and reshapes it internally.

  3. Take a 3-channel ImageNet-standardized tensor and run it through a 1×1 conv initialized (and frozen) to the luma weights [0.299, 0.587, 0.114]. Verify the output matches manual `rgb_to_grayscale` to within floating-point error, then decide: which other classical colour-space transforms can be written as 1×1 convolutions?
    Show one worked answer

    A 1×1 convolution computes a learnable weighted sum across the channel axis at every spatial position, so with the right weights it *is* a colour transform: ```python import torch, torch.nn as nn conv = nn.Conv2d(3, 1, kernel_size=1, bias=False) with torch.no_grad(): conv.weight.copy_(torch.tensor([[[[0.299]]], [[[0.587]]], [[[0.114]]]])) # (1,3,1,1) conv.weight.requires_grad_(False) gray = conv(x_standardized) # (N, 1, H, W) ``` Two details decide whether it *matches*. First, de-standardize before comparing: the conv operates on the standardized tensor, so the equivalent uint8 grayscale is `gray * std_mean_of_luma + mean_of_luma` — or simply run the conv on `[0,1]` values (then ×255 and round). Second, on the lesson's pixel (210, 140, 30) the manual sum is 0.299×210 + 0.587×140 + 0.114×30 = 62.79 + 82.18 + 3.42 = 148.39, and the conv returns 0.5820 on [0,1] input, i.e. 148.39/255 — identical up to float32 rounding (~1e-7 relative; PyTorch's default tolerance 1e-5 passes easily). Linearity is the test: **YCbCr is linear**, so Y, Cb and Cr can each be written as a 1×1 conv, and the three together are a 3×3 weight matrix (BT.601: Cb = 128 − 0.168736R − 0.331264G + 0.5B). **HSV is not linear** — its hue uses min/max and a branch on which channel is largest — so no single conv layer can produce it, which is why HSV lives in preprocessing code, not inside a network. This is the modern lesson hiding in the exercise: a trained 1×1 conv at the head of a network can learn any linear colour transform, including the BGR/RGB fix.

Terms this lesson borrows from later lessons (or outside)

You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.

  • tensor operationsIndexing, slicing, reshaping and transposing along named axes — the vocabulary this lesson uses when it says `arr[:, :, 0]` or `transpose(2, 0, 1)` and explains why an axis swap copies nothing. (Phase 1, Lesson 12)
  • Introduction to PyTorchThe lesson where tensors, `permute`, `unsqueeze`, dtypes and devices become hands-on, and where a wrong axis order turns from a shape question into a `RuntimeError`. (Phase 3, Lesson 11)
  • numerical stabilityWhy 8-bit arithmetic wraps at 256 (200 + 100 → 44) while float32 saturates at 3.4×10³⁸, and why an input 620× outside the trained range quietly saturates every activation. (Phase 1, Lesson 13)
  • Convolutions from ScratchThe next lesson in this phase, where the CHW layout finally pays off: kernels slide across two contiguous spatial planes instead of chasing interleaved channels. (Phase 4, Lesson 02)
  • Transfer Learning & Fine-TuningThe payoff for getting this contract exactly right: frozen ImageNet backbones are only useful when your preprocessing matches the one they were trained under. (Phase 4, Lesson 05)
KEEP GOING

A picture is a start.
Practice is the rest.

This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.

Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 01) and the Math Foundations Notebook reference build. The five labs (the pixel-grid sampler, the colour-space converter, the transpose board, the normalization calculator and the preprocessing stepper) are original to this page, as is the aliasing arithmetic (14 rings, 8 samples along a radius, fold to |14 − 2×8| = 2), the byte budget table (6,220,800 values for 1920×1080×3 = 6.22 MB uint8 / 24.88 MB float32, 150,528 for 224×224×3), the luma-versus-average comparison (148.39 vs 126.67), the RGB → HSV walk (36.7°, 0.857, 0.824), the BT.601 YCbCr numbers (Y 148.39, Cb 61.19, Cr 171.94) with the 4:2:0 saving, the shorter-side-256 → 224-crop arithmetic (455×256 → 50,176 of 116,480 pixels, 43.1%), the standardization walk with its 619× skip-÷255 failure, the uint8 wrap example (200 + 100 → 44) and the memory hook. Every number shown is computed live by the labs or verified by hand in the prose.