Lesson 1: Images as Arrays

A digital image is a 2D grid of numbers. In this lesson we build a small image from scratch with NumPy, look at how grayscale and color images are represented, and display them with Matplotlib.

In [1]:
import numpy as np
import cv2
import matplotlib.pyplot as plt

A grayscale image is a 2D array

Each entry is a pixel intensity. For an 8-bit image, pixel values range from 0 (black) to 255 (white).

In [2]:
im = np.zeros((100, 100), dtype=np.uint8)
im[20:80, 20:80] = 255   # a white square on a black background

print('shape:', im.shape, 'dtype:', im.dtype)

plt.imshow(im, cmap='gray', vmin=0, vmax=255)
plt.title('Grayscale image (100x100)')
plt.axis('off')
plt.show()
shape: (100, 100) dtype: uint8
No description has been provided for this image

Simple pixel-level operations

Because an image is just an array of numbers, standard NumPy operations apply directly, e.g., inverting intensities.

In [3]:
im_inv = 255 - im

fig, axes = plt.subplots(1, 2, figsize=(6, 3))
axes[0].imshow(im, cmap='gray', vmin=0, vmax=255)
axes[0].set_title('Original')
axes[0].axis('off')
axes[1].imshow(im_inv, cmap='gray', vmin=0, vmax=255)
axes[1].set_title('Inverted (255 - pixel)')
axes[1].axis('off')
plt.tight_layout()
plt.show()
No description has been provided for this image

A color image is a 3D array

An image's dimensions are (height, width, channels) — the output of array.shape. The first axis selects the row (moving down the image, i.e., $y$), the second axis selects the column (moving across, i.e., $x$); the third axis selects the color channel (red, green, or blue). By convention, the origin is the top-left corner.

In [4]:
imc = np.zeros((50, 100, 3), dtype=np.uint8)
imc[:, :, 0] = 255        # red channel on for the whole image
imc[10:40, 20:80, 1] = 255  # add green in the middle -> red + green looks yellow

print('shape:', imc.shape, 'dtype:', imc.dtype)

plt.imshow(imc)
plt.title('Color image (100x100x3)')
plt.axis('off')
plt.show()
shape: (50, 100, 3) dtype: uint8
No description has been provided for this image

Colors are typically in RGB order

Usually the order of the 3 color channels is red, green, and blue (RGB). To illustrate this, let's build a small image out of the six psychological primaries — arranged in a 2-row, 3-column grid. These are the six hues (black, white, red, yellow, green, blue) that the human visual system treats as elementary, unmixed colors.

In [5]:
im_primaries = np.zeros((2, 3, 3), dtype=np.uint8)
im_primaries[0, 0, :] = (0, 0, 0)  # black
im_primaries[0, 1, :] = (255, 255, 255)  # white
im_primaries[0, 2, :] = (255, 0, 0)  # red
im_primaries[1, 0, :] = (255, 255, 0)  # yellow
im_primaries[1, 1, :] = (0, 255, 0)  # green
im_primaries[1, 2, :] = (0, 0, 255)  # blue
print('shape:', im_primaries.shape, '  (2 rows, 3 columns, 3 color channels)')

plt.imshow(im_primaries)
plt.title('The six psychological primaries')
plt.axis('off')
plt.show()
shape: (2, 3, 3)   (2 rows, 3 columns, 3 color channels)
No description has been provided for this image

Slicing the first axis selects whole rows (a horizontal band of the image); slicing the second axis selects whole columns (a vertical band).

In [6]:
top_row = im_primaries[0:1, :, :]        # first axis sliced -> a full-width horizontal band
left_column = im_primaries[:, 0:1, :]    # second axis sliced -> a full-height vertical band
# NOTE: a bare integer index (im_primaries[0, :, :]) would DROP that axis entirely, turning
# shape (1, 3, 3) into (3, 3) -- imshow would then misread it as a 3x3 grayscale image instead
# of a 1-row RGB strip. Slicing with 0:1 keeps the axis alive as size 1.

fig, axes = plt.subplots(1, 3, figsize=(9, 3))
axes[0].imshow(im_primaries)
axes[0].set_title('Full image', fontsize=9)
axes[1].imshow(top_row)
axes[1].set_title(f'Top row  [0:1, :, :]', fontsize=9)
axes[2].imshow(left_column)
axes[2].set_title(f'Left column  [:, 0:1, :]', fontsize=9)
for ax in axes:
    ax.axis('off')
plt.tight_layout()
plt.show()
No description has been provided for this image

Loading a real photo: BGR vs. RGB

Every color image so far was manually constructed with the color channel in RGB order — matching what Matplotlib's plt.imshow expects. Real photos, however, are loaded with OpenCV's cv2.imread, which reads (and writes) color images with channels in BGR order — blue first, red last — the opposite of the RGB order every other Python imaging/plotting library assumes. Handing a BGR array straight to plt.imshow misinterprets these color channels, resulting in a weird display.

In [7]:
im_bgr = cv2.imread('../img/rose.jpg')
print('shape:', im_bgr.shape, ' (last axis is in BGR order)')

plt.imshow(im_bgr)
plt.title('im_bgr shown directly -- wrong colors!')
plt.axis('off')
plt.show()
shape:
 (209, 309, 3)  (last axis is in BGR order)
No description has been provided for this image

Two ways to fix this problem

  1. Reverse the channel axis with NumPy indexing. img[:, :, ::-1] reverses the order of the last axis, turning [B, G, R] into [R, G, B] — a plain array operation, no OpenCV function needed.
  2. cv2.cvtColor. OpenCV's general-purpose color-conversion function; cv2.COLOR_BGR2RGB does exactly the same channel swap, but is more explicit about why, and is the idiomatic choice in OpenCV code (the same function also handles conversions that aren't a simple reversal, e.g. to grayscale or HSV, in later lessons).

Both should produce identical results here, since a BGR→RGB conversion is nothing more than reversing three channels.

In [8]:
im_rgb1 = im_bgr[:, :, ::-1]
im_rgb2 = cv2.cvtColor(im_bgr, cv2.COLOR_BGR2RGB)

print('the two methods agree exactly:', np.array_equal(im_rgb1, im_rgb2))

fig, axes = plt.subplots(1, 3, figsize=(10, 4))
for ax, im, title in zip(axes, [im_bgr, im_rgb1, im_rgb2],
                          ['im_bgr (wrong)', 'img[:, :, ::-1]', "cv2.cvtColor(..., BGR2RGB)"]):
    ax.imshow(im)
    ax.set_title(title, fontsize=9)
    ax.axis('off')
plt.tight_layout()
plt.show()
the two methods agree exactly: True
No description has been provided for this image

Image source: Picryl

Exercise

  1. Modify the color image imc so the rectangle is cyan instead of yellow. Which channel(s) do you need to change?
  2. In im_primaries, slice out the bottom-right block (blue) using row and column ranges, and confirm with np.array_equal that it matches a fresh block filled with (0, 0, 255).
  3. Load rose.jpg and print im_bgr[0, 0, :] (top-left pixel, BGR order) alongside im_rgb2[0, 0, :] (RGB order). Confirm by hand that the three numbers are the same three numbers, just reordered.