Discord

6Computer Vision 6.1Image Modality Basics

6.1.1 Basic Image Terminology

What an image is to a computer: a grid of pixels, one or more channels, whole numbers from 0 to 255, and the shapes NumPy and PyTorch give it.

Why learn image terminology?

In A Simple MLP in PyTorch, we trained a network on handwritten digits. Each digit was an image of 8 by 8 pixels, but we never treated it as one: scikit-learn had already flattened it into a row of 64 numbers, and the network read it like any other table. That worked for tiny, clean images. It stops working for real photographs, which have hundreds of thousands of pixels and colour.

This part of the guide is about images read as images. Before any model, you need the words and the shapes: what a pixel is, what a channel is, why the numbers go from 0 to 255, and why the same photo has shape (430, 500, 3) in NumPy but (3, 430, 500) in PyTorch. Images are one of the main kinds of data in machine learning, and most bugs in image code are not in the model at all. They are a width swapped with a height, channels in the wrong place, or pixels left as whole numbers from 0 to 255. This page gives you the vocabulary to avoid them.

What you need to know

By the end of this module, you should be comfortable with these ideas:

Idea In one line
Pixel One cell of the grid that makes up an image.
Resolution The size of the grid, written width × height, such as 500 × 430.
Channel One number per pixel for one property: brightness for grayscale, or red, green and blue for colour.
Grayscale, RGB, RGBA One channel; three channels; three channels plus transparency (alpha).
uint8 Whole numbers from 0 to 255, the usual type of a pixel value in a file.
(H, W, C) The shape NumPy and Pillow use: rows, then columns, then channels.
(C, H, W) and (N, C, H, W) The shapes PyTorch uses: channels first, and a batch of NN images in front.
Scaling to [0,1][0, 1] Divide by 255 and convert to floats before a network sees the pixels.
JPEG and PNG JPEG files are small but change the pixels slightly (lossy); PNG files keep them exactly (lossless).

Learn it properly

The ideas here are few, but they come back in every image task. Read one course section on them, then use this page to see each one in code on a real photograph.

Pick one main course

This topic is short, so one lesson is enough.

Our pick: Data Carpentry's Image Basics

Read Image Basics, the first real episode of Data Carpentry's free lesson Image Processing with Python. It explains pixels with a zoomed-in photo, then the coordinate system (row first, with the origin at the top left), the RGB colour model, and the common file formats, including the difference between lossy and lossless compression. Do the short exercises as you go.

The lesson loads images with a library called imageio, which is not on the list of libraries for the 2026 contest machines. Read past that detail: the arrays it produces are exactly the NumPy arrays you get from Pillow on this page.

If you prefer something else

  • A short reference: scikit-image's A crash course on NumPy for images covers the shapes, indexing by row and column, and colour channels in a few screens.
  • Pillow's own words: the Concepts page of the Pillow handbook defines bands (Pillow's name for channels), modes such as L, RGB and RGBA, size and coordinates. Useful to keep open while you read Reading and Manipulating Images with Pillow.
  • From another angle: the background section of CS50x's Filter problem set explains 24-bit colour, with 8 bits each for red, green and blue. The problem itself is written in C, so read it for the explanation.

A quick tour

The rest of this page follows an image from pixels you can see to the tensor a model reads. We will start with the familiar digits, then use one colour photo to see how position, colour and pixel values fit together. Go through your chosen course first, then run each cell in a notebook as you read.

An image is a grid of numbers

Let us start with the digits from A Simple MLP in PyTorch. Besides the flattened rows in digits.data, scikit-learn also keeps each digit as a grid, in digits.images:

INPUTPython
from sklearn.datasets import load_digits

digits = load_digits()
first_digit = digits.images[0]
print(first_digit.shape)
print(first_digit.astype(int))
[OUTPUT]
(8, 8)
[[ 0  0  5 13  9  1  0  0]
 [ 0  0 13 15 10 15  5  0]
 [ 0  3 15  2  0 11  8  0]
 [ 0  4 12  0  0  8  8  0]
 [ 0  5  8  0  0  9  8  0]
 [ 0  4 11  0  1 12  7  0]
 [ 0  2 14  5 10 12  0  0]
 [ 0  0  6 13 10  0  0  0]]

Look at where the large numbers are: they trace the outline of a 0. Each number is one pixel (short for "picture element"), one cell of the grid. Here a pixel holds how dark the ink is, from 0 (blank) to 16 (fully dark). An image, to a computer, is nothing more than this: a grid of numbers, one per pixel.

The 64 numbers that the network read in that module are the same grid, read row by row and laid end to end:

INPUTPython
import numpy as np

flattened = first_digit.flatten()      # row 0, then row 1, ...
np.array_equal(flattened, digits.data[0])
[OUTPUT]
True

Flattening throws away nothing, but it hides which pixels are next to which. Pixel 7 (the end of the first row) and pixel 8 (the start of the second) sit side by side in the row of 64, although they are at opposite edges of the image. Pixel 0 and pixel 8, which really are neighbours, are 8 places apart. The models in Convolutional Layers keep the grid, so they can use exactly this information. Now let us see how the same grid idea works for a photograph.

Pixels and resolution

Real photographs are the same idea with many more pixels. This page uses one photo of a blue-and-yellow macaw, shrunk to 500 pixels wide. You can download macaw.jpg and put it next to your notebook. We open it with Pillow, the image library on the contest machines, and turn it into a NumPy array:

INPUTPython
from PIL import Image

photo = Image.open("macaw.jpg")
pixels = np.asarray(photo)
print(photo.size)      # Pillow: (width, height)
print(pixels.shape)    # NumPy: (height, width, channels)
print(pixels.dtype)
[OUTPUT]
(500, 430)
(430, 500, 3)
uint8
A colour photograph of the head and shoulders of a blue-and-yellow macaw, facing right, against a blurred green background. Its back and wing are bright blue, its chest bright yellow, its face white with thin black stripes, and its beak large and dark grey.
The photo used in this part of the guide: Ara ararauna portrait Rhodes by Jebulon, CC0, resized to 500 × 430 pixels.

The resolution of an image is the size of its grid. People and Pillow write it as width × height, so this photo is 500 × 430: 500 pixels across and 430 down. NumPy writes the same thing the other way round, because an array is indexed by row first: 430 rows, each 500 pixels long. The third number, 3, tells us that each pixel holds three colour values. We will look at those shortly. The uint8 on the last line names the type of those values; we will come back to that too.

This difference in order is the most common source of bugs in image code. Keep it in mind every time you read a size:

Where What it says Order
photo.size (Pillow) (500, 430) width, height
pixels.shape (NumPy) (430, 500, 3) height, width, channels

The photo has 430×500=215,000430 \times 500 = 215{,}000 pixels, and 215,000×3=645,000215{,}000 \times 3 = 645{,}000 numbers in total. Compare that with the 64 numbers of a digit.

Rows, columns and coordinates

To read one pixel, index the array by row and then column, pixels[row, column]. Row 0 is the top of the image and column 0 is the left edge, so the origin is the top-left corner, and rows count downwards, unlike the yy axis of a graph. In Pillow, and in most image tools, a position is written (x, y): the column first, then the row. So the pixel that Pillow calls (x, y) is pixels[y, x] in NumPy.

INPUTPython
print(pixels[0, 0])        # top-left corner
print(pixels[300, 150])    # row 300, column 150: the yellow chest
print(photo.getpixel((150, 300)))   # the same pixel, as (x, y)
[OUTPUT]
[37 50  0]
[245 185   3]
(245, 185, 3)

We can now find a pixel's position. What does that pixel actually hold? It is easiest to see in a grayscale image, where there is just one value per position.

Grayscale: one number per pixel

The simplest images have one number per pixel: its brightness, from 0 for black to 255 for white. These are grayscale images. Pillow converts a colour photo to grayscale with convert("L") (L stands for luminance, another word for brightness):

INPUTPython
gray = np.asarray(photo.convert("L"))
print(gray.shape)
print(gray[121:126, 312:317])    # 5 rows and 5 columns by the eye
[OUTPUT]
(430, 500)
[[13  9 10 16 21]
 [10  3  4  5  1]
 [ 1  1 10  8  3]
 [37 21  9  0  2]
 [39 15  2  5 16]]

A grayscale image has only two dimensions, height and width: there is no channel dimension. The small block printed above is the dark pupil of the macaw's eye, where the values are close to 0. The figure shows a larger block around the same spot, with every value written in:

Left: the macaw photo in grayscale, with axes from 0 to 499 columns across and 0 to 429 rows down, and a small red square on the eye at about column 315, row 123. Right: that square magnified to a 10 by 10 grid of grey cells, rows 118 to 127 and columns 310 to 319, each cell labelled with its value. The values near the centre, the pupil, are between 0 and 15; those around it are between 40 and 100, and one cell in the bottom-left corner is 143.
A 10 × 10 block of the grayscale photo, around the eye. Every pixel is one whole number from 0 (black) to 255 (white).

How does Pillow decide the brightness of a coloured pixel? Its documentation gives the formula: L=0.299R+0.587G+0.114BL = 0.299R + 0.587G + 0.114B, stored as a whole number. Green counts the most because our eyes are most sensitive to it. For the yellow chest pixel above, 0.299×245+0.587×185+0.114×3≈182.20.299 \times 245 + 0.587 \times 185 + 0.114 \times 3 \approx 182.2:

INPUTPython
print(gray[300, 150])
[OUTPUT]
182

The grayscale photo needed one brightness value at each position. The original macaw photo is in colour, so what does its pixel hold?

Colour: three channels

A colour pixel is made by mixing red, green and blue light, so a colour image stores three numbers per pixel, one for each. This way of storing colour is called RGB. A channel is one complete grid of measurements over the image: the red channel has one red value at every pixel, the green channel has one green value at every pixel, and so on. Think of the three grids stacked together, with one value from each grid at any position. In NumPy the channels are the last dimension, so pixels[:, :, 0] gives us the whole red grid.

Why these three colours? Our eyes have three types of colour-sensitive cells that respond to different, overlapping ranges of light. Mixing red, green and blue light lets a screen produce many colours we can see, so RGB is a natural choice for ordinary photographs and displays. The three numbers are a way to represent colour for us; they are not a limit on how many measurements an image can hold. The National Eye Institute explains how the three types of cells contribute to colour vision.

The yellow chest pixel was [245 185 3]: a lot of red, a fair amount of green, almost no blue. Red and green light together look yellow, which is why yellow needs no blue at all. Here are a few more pixels from the photo:

INPUTPython
print(pixels[350, 370])    # blue wing
print(pixels[190, 290])    # white face
print(pixels[180, 300])    # a grey stripe on the face
[OUTPUT]
[ 30  98 163]
[233 229 230]
[78 74 75]

The blue wing has mostly blue. White is all three channels high; grey is all three about equal, at a middle value; black would be all three near 0. Splitting the photo into its three channels shows the whole picture at once:

Four versions of the macaw photo side by side. First, the colour photo. Then the red channel as a grayscale image: the yellow chest is almost white and the blue wing is dark. The green channel: the chest is light grey and the green crown and background are medium grey. The blue channel: the chest is almost black, while the blue wing and back are bright.
The photo and its three channels, each shown in grayscale: light means a high value. The yellow chest is bright in red and green and black in blue.

So the shape (430, 500, 3) reads as: 430 rows, 500 columns, and 3 channels per pixel. This order, height, width, channels, is called channels-last or HWC, and it is what Pillow, NumPy, Matplotlib and OpenCV all use.

Other cameras can record more than the three values needed for an RGB photo. Each extra channel is another grid measured at the same positions, perhaps for a different range of light that our eyes cannot see. In IOAI 2025's at-home Task 2, Satellite Weather Forecasting, the satellite images had 16 spectral channels: three for visible light, three for near-infrared and ten for infrared. The model could use information from all 16, even though we could not display those measurements as an ordinary RGB photo. The idea of a channel stays the same; only what each grid measures changes.

OptionalOpenCV stores channels as BGRIf you load an image with OpenCV instead of Pillow, the three channels come in the opposite order.1 min readShowHide

OpenCV (cv2, also on the contest machines) reads colour images with the channels in the order blue, green, red. A photo loaded with cv2.imread and shown with Matplotlib therefore has its red and blue swapped: the macaw would come out with an orange back and a blue chest. Convert with cv2.cvtColor(image, cv2.COLOR_BGR2RGB), or reverse the last dimension with image[:, :, ::-1]. This guide uses Pillow, which gives RGB, so you do not need this unless you use OpenCV.

Transparency: RGBA

Some images, especially PNG files such as logos and icons, have a fourth channel called alpha. It says how opaque each pixel is: 0 is fully transparent, 255 fully solid. Pillow calls this mode RGBA. Here is a tiny image of 4 × 2 pixels, all a half-transparent blue:

INPUTPython
badge = Image.new("RGBA", (4, 2), (31, 72, 199, 128))
badge_pixels = np.asarray(badge)
print(badge.mode, badge_pixels.shape)
print(badge_pixels[0, 0])
[OUTPUT]
RGBA (2, 4, 4)
[ 31  72 199 128]

Four channels instead of three. A model built for RGB photos expects exactly three, so an RGBA file has to be converted first, usually with convert("RGB"), which drops the alpha channel:

INPUTPython
badge_rgb = badge.convert("RGB")
print(badge_rgb.mode, np.asarray(badge_rgb).shape)
[OUTPUT]
RGB (2, 4, 3)

Dropping alpha keeps whatever colour was stored under the transparent pixels, which is often black. Reading and Manipulating Images with Pillow shows how to put a transparent image on a white background instead, and how to convert every image in a dataset to the same mode.

Whether a pixel holds one, three or four values, we also need to know what kind of numbers those values are. That determines both their range and what happens when we calculate with them.

Whole numbers from 0 to 255

Look again at pixels.dtype: it was uint8, an unsigned 8-bit integer. Eight bits can store 28=2562^8 = 256 different values, and "unsigned" means no negative numbers, so a uint8 holds the whole numbers from 0 to 255. That is enough levels for the eye, at one byte per channel per pixel. (The digits we started with were stored differently, as floats from 0 to 16, because they were made by counting dark pixels in a larger scan.)

The small range has a trap. Arithmetic on uint8 arrays stays uint8, and a result that does not fit wraps around instead of failing:

INPUTPython
bright = np.array([250], dtype=np.uint8)
dark = np.array([3], dtype=np.uint8)
print(bright + 10)    # 260 does not fit: it wraps around to 4
print(dark - 5)       # -2 does not fit either: 254
[OUTPUT]
[4]
[254]

There is no error and no warning: a bright pixel has silently become almost black. Before doing any arithmetic on pixels, such as brightening an image or averaging two, convert them to floats.

That conversion also gives us the kind of values we want to feed into a network. We will convert the photo to floats and put its pixel values on a smaller scale.

Scaling to between 0 and 1

Networks train best on small numbers on a similar scale, for the reasons in Gradient Descent. For images, the usual first step is the one we used for the digits: divide by the largest possible value, here 255, so that every pixel becomes a float between 0 and 1.

INPUTPython
scaled = pixels.astype(np.float32) / 255
print(scaled.dtype, scaled.shape)
print(scaled.min(), scaled.max())
print(scaled[300, 150])
[OUTPUT]
float32 (430, 500, 3)
0.0 1.0
[0.9607843  0.7254902  0.01176471]

The shape is unchanged; only the type and the range are different. float32 is the type PyTorch models use. Many models go one step further and normalise each channel with a mean and a standard deviation; Loading Images into Tensors with Torchvision shows how, and where those numbers come from.

We now know what each value means and how to prepare it. One last detail changes when we hand the image to PyTorch: where the channel dimension goes.

Channels first in PyTorch

PyTorch puts the channels in front: one image is a tensor of shape (C, H, W), channels-first. The reason is practical. The layers in Convolutional Layers work on one channel's whole grid at a time, and with channels first, each channel is one contiguous (H, W) block. You will rarely do the conversion by hand, because torchvision's transforms do it for you, but it is worth seeing once:

INPUTPython
import torch

image_tensor = torch.tensor(scaled)       # (H, W, C), float32
print(image_tensor.shape, image_tensor.dtype)
image_tensor = image_tensor.permute(2, 0, 1)    # (C, H, W)
print(image_tensor.shape)
[OUTPUT]
torch.Size([430, 500, 3]) torch.float32
torch.Size([3, 430, 500])

permute(2, 0, 1) builds a new order of dimensions: the new first dimension is the old dimension 2 (channels), then the old 0 (height), then the old 1 (width). Now image_tensor[0] is the whole red channel.

A tempting shortcut is reshape(3, 430, 500). It gives the right shape and the wrong image. Reshaping keeps the numbers in their original order and only cuts them into new blocks, so the first "channel" would be the first third of the numbers: the R, G and B values of the top rows, all mixed together. Tensors, Matrices, and Related Mathematical Functions explains why. We can check:

INPUTPython
red_channel = torch.tensor(scaled[:, :, 0])
reshaped = torch.tensor(scaled).reshape(3, 430, 500)
print(torch.equal(image_tensor[0], red_channel))
print(torch.equal(reshaped[0], red_channel))
[OUTPUT]
True
False

To move channels, use permute; to change the number of dimensions without moving data, use reshape or unsqueeze.

A batch of images: (N, C, H, W)

Models take a batch of samples at once, with the batch as the first dimension, as in The batch dimension. For images, that makes four dimensions: (N, C, H, W), for NN images with CC channels of H×WH \times W pixels each. Stacking the photo with its mirror image gives a batch of two:

INPUTPython
mirrored = image_tensor.flip(2)         # flip the width dimension
batch = torch.stack([image_tensor, mirrored])
batch.shape
[OUTPUT]
torch.Size([2, 3, 430, 500])

Grayscale images get a channel dimension of size 1, even though it holds only one number per pixel, so that every image batch has the same four dimensions. Here is the whole digits dataset in the shape a convolutional network expects:

INPUTPython
all_digits = torch.tensor(digits.images, dtype=torch.float32)
print(all_digits.shape)                  # (N, H, W)
all_digits = all_digits.unsqueeze(1)     # add C = 1 at position 1
print(all_digits.shape)                  # (N, C, H, W)
[OUTPUT]
torch.Size([1797, 8, 8])
torch.Size([1797, 1, 8, 8])

Convolutional Neural Networks trains a network on exactly this tensor. When you read image code, it helps to write the shape as a comment after each line, as above.

We have followed the photo into a model-ready batch. Before leaving the image file behind, let us look at why saving the same photo in two formats can give different pixel values when we open it again.

File formats: JPEG and PNG

On disk, images are stored compressed, in a file format. Two cover almost everything you will meet:

  • JPEG (.jpg, .jpeg) is lossy: to make files small, it throws away detail that the eye barely notices, so the pixels you read back are slightly different from the ones that were saved. It is the usual format for photographs.
  • PNG (.png) is lossless: the pixels come back exactly. Files are larger for photos, but PNG is the usual format for drawings, screenshots, masks and images with transparency.

Let us save the photo both ways, once as PNG and once as a JPEG at a low quality setting, and compare:

INPUTPython
import os

photo.save("macaw.png")
photo.save("macaw_q30.jpg", quality=30)    # quality: 0 to 95
for name in ["macaw.jpg", "macaw.png", "macaw_q30.jpg"]:
    size_kb = os.path.getsize(name) / 1024
    print(name, round(size_kb), "KB")
[OUTPUT]
macaw.jpg 48 KB
macaw.png 341 KB
macaw_q30.jpg 17 KB
INPUTPython
from_png = np.asarray(Image.open("macaw.png"))
from_jpeg = np.asarray(Image.open("macaw_q30.jpg"))
print(np.array_equal(from_png, pixels))
print(np.array_equal(from_jpeg, pixels))

difference = np.abs(from_jpeg.astype(int) - pixels.astype(int))
print(round(difference.mean(), 2), difference.max())
[OUTPUT]
True
False
5.38 109

The file macaw.jpg was itself saved as a JPEG, at quality 85, when we shrank the original photo; its pixels are what we compare against. The PNG is seven times larger and gives back every pixel exactly. The low-quality JPEG is small, and its pixels are off by a few levels on average, with some much further off. Notice the astype(int) before subtracting: without it, the uint8 subtraction would wrap around, as we saw above. In memory, both files become the same kind of array, (430, 500, 3) of uint8, which takes 645,000645{,}000 bytes however small the file was.

Pillow opens all the common formats, so in a task you simply read what you are given. The difference matters when you save images yourself: save masks and anything else you must read back exactly as PNG, never as JPEG.

Common mistakes

The messages are exactly what NumPy, PyTorch and Matplotlib print. Some use nn.Conv2d, the layer from the next module; for now, read it as "a layer that expects 3 channels".

Mistake What you see Fix
Indexing pixels[x, y] instead of pixels[y, x] IndexError: index 450 is out of bounds for axis 0 with size 430 (or, worse, the wrong pixel and no error) Index NumPy arrays by row, then column: pixels[y, x].
Treating photo.size as (height, width) No error, but crops and resizes come out with the sides swapped Pillow's size is (width, height); NumPy's shape is (height, width, channels).
Adding or subtracting uint8 pixels No error, but values wrap around: 250 + 10 gives 4 Convert first: pixels.astype(np.float32) or .astype(int).
Giving a model channels-last images RuntimeError: Given groups=1, weight of size [8, 3, 3, 3], expected input[1, 430, 500, 3] to have 3 channels, but got 430 channels instead permute(0, 3, 1, 2) a batch, or permute(2, 0, 1) one image, to put channels first.
Giving a model uint8 pixels RuntimeError: Input type (unsigned char) and bias type (float) should be the same Convert to float32 and divide by 255.
An RGBA image where RGB is expected RuntimeError: Given groups=1, weight of size [8, 3, 3, 3], expected input[1, 4, 430, 500] to have 3 channels, but got 4 channels instead image.convert("RGB") when you open each file.
A grayscale image with no channel dimension RuntimeError: Expected 3D (unbatched) or 4D (batched) input to conv2d, but got input of size: [8, 8] Add the channel (and batch) dimension with unsqueeze.
Showing floats from 0 to 255 with plt.imshow Clipping input data to the valid range for imshow with RGB data ([0..1] for floats or [0..255] for integers). Got range [0.0..255.0]. and an almost white picture Show uint8 values from 0 to 255, or floats from 0 to 1.
reshape instead of permute to move channels No error, but the image is scrambled Use permute to reorder dimensions.

How to practise

There is no image section on the practice sites we use (CodeChef, CS50 and HackerRank), but the array skills behind this page are practised there, and the photo gives you plenty to try:

  • On HackerRank's NumPy track, solve Shape and Reshape, Transpose and Flatten and Concatenate. Moving an image between (H, W, C) and (C, H, W), or stacking images into a batch, uses exactly these operations.
  • Load the photo and find the brightest and darkest pixels of each channel with argmax and argmin. Convert each flat index back to a (row, column) pair with np.unravel_index, and check in the picture that the places make sense.
  • Make the photo grayscale yourself with the formula 0.299R+0.587G+0.114B0.299R + 0.587G + 0.114B on the float array, and compare your result with convert("L"). How many pixels differ, and by how much?

Check yourself

What comes next

Resources

SourceTitleWhy read it
Data CarpentryImage Processing with Python: Image BasicsOur recommended starting point: pixels, coordinates, RGB colour and file formats, with exercises.
scikit-imageA crash course on NumPy for imagesImages as NumPy arrays: shapes, indexing by row and column, and colour channels.
PillowConceptsPillow's own definitions of bands (channels), modes such as L, RGB and RGBA, size and coordinates.
CS50xProblem Set 4: FilterImages as grids of red, green and blue values, and filters that change them. Written in C.
Wikimedia CommonsAra ararauna portrait Rhodes.jpgThe photograph used on this page, by Jebulon, released under CC0 (public domain dedication).

Finished this module? Mark it done.

Comments

Stuck on something, spotted a mistake, or found a resource that helped you? Tell us below.

Loading comments