NumPy Image Arrays

Reviewed & published by Brayan K

Every image you have ever opened is a grid of numbers, and NumPy is how Python holds that grid. Once you know the shape convention and the right data type, creating a blank canvas, reading a pixel, or isolating a colour channel all become ordinary array operations.

Part of the free NumPy course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

We will start with the question people most often arrive at NumPy asking — how do I make a 210×210 black image? — and build out from there.

What You'll Learn in This Lesson

1 The Shape Rule: Height, Width, Channels

An image array has one of three shapes:

So a 210×210 image is (210, 210, 3) — square, so the swap does not bite here, which is exactly why square examples hide the bug until you try a rectangle.

import numpy as np

# A 210x210 black RGB image
black = np.zeros((210, 210, 3), dtype=np.uint8)
print("shape:", black.shape)
print("dtype:", black.dtype)
print("top-left pixel:", black[0, 0])
print()

# Grayscale needs no channel axis
gray = np.zeros((210, 210), dtype=np.uint8)
print("grayscale shape:", gray.shape)
print()

# A rectangle makes the ordering obvious: 1920 wide, 1080 tall
photo = np.zeros((1080, 1920, 3), dtype=np.uint8)
print("a '1920x1080' photo has shape:", photo.shape)
# ✅ Expected output:
# shape: (210, 210, 3)
# dtype: uint8
# top-left pixel: [0 0 0]
#
# grayscale shape: (210, 210)
#
# a '1920x1080' photo has shape: (1080, 1920, 3)

2 Why uint8 — and the np.ones() Trap

Each channel stores a whole number from 0 (none) to 255 (full). That is precisely one byte, which is what np.uint8 gives you — and what every image format and viewer expects.

The memory difference is not trivial. A 4K RGB frame is about 25 MB as uint8, and nearly 200 MB as the float64 NumPy would otherwise default to — eight times the memory for data that only ever holds whole numbers 0-255.

To make any flat colour, fill with a three-value list. Remember the order is red, green, blue — so pure red is [255, 0, 0].

import numpy as np

h, w = 4, 4  # small so we can print it

white = np.full((h, w, 3), 255, dtype=np.uint8)
print("white pixel :", white[0, 0])

nearly_black = np.ones((h, w, 3), dtype=np.uint8)
print("np.ones gives:", nearly_black[0, 0], "<- not white!")
print()

# A flat colour: red, green, blue
red = np.full((h, w, 3), [255, 0, 0], dtype=np.uint8)
sky = np.full((h, w, 3), [135, 206, 235], dtype=np.uint8)
print("red pixel:", red[0, 0])
print("sky pixel:", sky[0, 0])
print()

# Memory: uint8 vs the float64 default
print("uint8   MB:", round(np.zeros((2160, 3840, 3), dtype=np.uint8).nbytes / 1e6, 1))
print("float64 MB:", round(np.zeros((2160, 3840, 3)).nbytes / 1e6, 1))
# ✅ Expected output:
# white pixel : [255 255 255]
# np.ones gives: [1 1 1] <- not white!
#
# red pixel: [255   0   0]
# sky pixel: [135 206 235]
#
# uint8   MB: 24.9
# float64 MB: 199.1

3 Pixels, Regions and Channels

Because it is just an array, ordinary indexing does everything:

Slicing returns a view, not a copy, so assigning into a slice edits the original image directly — which is exactly what you want when drawing.

import numpy as np

img = np.zeros((6, 8, 3), dtype=np.uint8)   # 6 rows tall, 8 columns wide

# Draw a white block: rows 1-2, columns 2-4
img[1:3, 2:5] = 255

# One red pixel at row 4, column 6
img[4, 6] = [255, 0, 0]

# Print the red channel so the shapes are visible as numbers
print("red channel:")
print(img[:, :, 0])
print()
print("pixel at [1, 2]:", img[1, 2])
print("pixel at [4, 6]:", img[4, 6])
print("green channel at [4, 6]:", img[4, 6, 1])
# ✅ Expected output:
# red channel:
# [[  0   0   0   0   0   0   0   0]
#  [  0   0 255 255 255   0   0   0]
#  [  0   0 255 255 255   0   0   0]
#  [  0   0   0   0   0   0   0   0]
#  [  0   0   0   0   0   0 255   0]
#  [  0   0   0   0   0   0   0   0]]
#
# pixel at [1, 2]: [255 255 255]
# pixel at [4, 6]: [255   0   0]
# green channel at [4, 6]: 0

4 Two Bugs That Turn Images Black

1. uint8 overflow. Brightening with img + 50 looks sensible until a channel already at 230 wraps past 255 and comes back near zero — bright areas turn dark and speckled. Do the arithmetic in a wider type and clip:

np.clip(img.astype(np.int16) + 50, 0, 255).astype(np.uint8)

2. Float-to-uint8 without scaling. Many libraries hand back floats in the range 0–1. Casting straight to uint8 truncates every one of them to 0 — a perfectly black image. Multiply by 255 first.

import numpy as np

# --- Bug 1: overflow ---
img = np.full((2, 3, 3), 230, dtype=np.uint8)
print("naive  +50:", (img + 50)[0, 0], "<- wrapped around")
fixed = np.clip(img.astype(np.int16) + 50, 0, 255).astype(np.uint8)
print("clipped+50:", fixed[0, 0])
print()

# --- Bug 2: unscaled floats ---
floats = np.array([[[0.0, 0.5, 1.0]]])       # a 0-1 float image
print("straight cast :", floats.astype(np.uint8)[0, 0], "<- all black")
print("scaled first  :", (floats * 255).astype(np.uint8)[0, 0])
print()

print("diagnosis -> dtype:", floats.dtype, "min:", floats.min(), "max:", floats.max())
# ✅ Expected output:
# naive  +50: [24 24 24] <- wrapped around
# clipped+50: [255 255 255]
#
# straight cast : [0 0 1] <- all black
# scaled first  : [  0 127 255]
#
# diagnosis -> dtype: float64 min: 0.0 max: 1.0

5 🎯 Your Turn

Build a small flag: a black canvas, a white horizontal band across the middle, and a red square in the top-left corner. Then confirm the shape and a couple of pixels.

import numpy as np

H, W = 8, 12
flag = np.zeros((H, W, 3), dtype=np.uint8)

# White band across the middle two rows
flag[3:5, :] = 255

# Red square, 3x3, top-left
flag[0:3, 0:3] = [255, 0, 0]

print("shape:", flag.shape)
print("centre pixel  [3, 6]:", flag[3, 6])
print("corner pixel  [0, 0]:", flag[0, 0])
print("untouched     [7, 11]:", flag[7, 11])
print()
print("red channel:")
print(flag[:, :, 0])
# ✅ Expected output:
# shape: (8, 12, 3)
# centre pixel  [3, 6]: [255 255 255]
# corner pixel  [0, 0]: [255   0   0]
# untouched     [7, 11]: [0 0 0]
#
# red channel:
# [[255 255 255   0   0   0   0   0   0   0   0   0]
#  [255 255 255   0   0   0   0   0   0   0   0   0]
#  [255 255 255   0   0   0   0   0   0   0   0   0]
#  [255 255 255 255 255 255 255 255 255 255 255 255]
#  [255 255 255 255 255 255 255 255 255 255 255 255]
#  [  0   0   0   0   0   0   0   0   0   0   0   0]
#  [  0   0   0   0   0   0   0   0   0   0   0   0]
#  [  0   0   0   0   0   0   0   0   0   0   0   0]]

❓ Frequently Asked Questions

Lesson complete — images are just arrays now!

You can create a canvas at any size, pick the right dtype, address pixels and channels by index, and recognise the two dtype bugs that turn an image unexpectedly black.

Practice quiz

What shape does a 210x210 colour image have as a NumPy array?

  • (210, 210)
  • (210, 210, 3)
  • (3, 210, 210)
  • (210, 3)

Answer: (210, 210, 3). Height, then width, then one value per colour channel — (210, 210, 3) for RGB.

Which call creates a 210x210 black RGB image?

  • np.zeros((210, 210, 3), dtype=np.uint8)
  • np.empty((210, 210))
  • np.ones((210, 210, 3))
  • np.zeros(210)

Answer: np.zeros((210, 210, 3), dtype=np.uint8). Black is zero in every channel, and uint8 is the standard image data type.

Why use dtype=np.uint8 for images?

  • It is faster to type
  • It matches the 0-255 range each channel uses, at one byte per value
  • It supports negative numbers
  • It is required by NumPy

Answer: It matches the 0-255 range each channel uses, at one byte per value. uint8 holds exactly 0-255, which is the range image formats and viewers expect.

How do you make a pure white RGB image?

  • np.ones((h, w, 3), dtype=np.uint8)
  • np.full((h, w, 3), 255, dtype=np.uint8)
  • np.zeros((h, w, 3)) + 1
  • np.white((h, w, 3))

Answer: np.full((h, w, 3), 255, dtype=np.uint8). White is 255 in every channel; np.ones would give you a near-black value of 1.

In img[50, 100], what does 50 refer to?

  • The x coordinate
  • The row (y coordinate)
  • The colour channel
  • The width

Answer: The row (y coordinate). NumPy indexes rows first, so img[y, x] — the opposite order to most graphics APIs.

What shape does a grayscale image have?

  • (height, width)
  • (height, width, 1) only
  • (height, width, 3)
  • (width, height)

Answer: (height, width). Grayscale needs one value per pixel, so it is a plain 2-D array.

What does img[:, :, 0] select in an RGB image?

  • The first row
  • The red channel of every pixel
  • The first pixel
  • The alpha channel

Answer: The red channel of every pixel. Take all rows, all columns, channel index 0 — the red plane.

With uint8, what is 250 + 10?

  • 260
  • 255
  • 4
  • An error

Answer: 4. uint8 wraps around: it overflows past 255 and comes back to 4. Cast to a wider type before arithmetic.

How do you convert a float image in the range 0-1 to uint8?

  • img.astype(np.uint8)
  • (img * 255).astype(np.uint8)
  • np.round(img)
  • img.tolist()

Answer: (img * 255).astype(np.uint8). Scale to 0-255 first — casting straight away would turn every value below 1.0 into 0.

How many channels does an RGBA image have?

  • 3
  • 4
  • 2
  • 1

Answer: 4. Red, green, blue plus alpha for transparency — shape (h, w, 4).

Continue this course

Frequently asked questions

How do I create a 210x210 black image as a NumPy array?

Use np.zeros((210, 210, 3), dtype=np.uint8). Black is zero in every colour channel, the shape is height then width then three RGB channels, and uint8 is the 0-255 data type image tools expect. For a grayscale version drop the last dimension: np.zeros((210, 210), dtype=np.uint8).

What shape is an image as a NumPy array?

(height, width, channels). A 1920x1080 photo is therefore (1080, 1920, 3) — height comes first, which catches people out because image dimensions are usually quoted width-first. Grayscale images are simply (height, width), and RGBA images are (height, width, 4).

Why do images use uint8 instead of int or float?

Each colour channel stores a whole number from 0 to 255, and uint8 is exactly that range in a single byte. It keeps memory small — a 4K RGB frame is about 25 MB as uint8 but nearly 200 MB as float64 — and it is what image libraries and file formats expect when saving.

Why is my image all black after doing maths on it?

Usually a dtype problem. Either you produced floats in the 0-1 range and cast straight to uint8, which rounds everything below 1.0 down to 0 — multiply by 255 first — or your uint8 arithmetic overflowed and wrapped around past 255.

How do I make a white image instead of a black one?

np.full((height, width, 3), 255, dtype=np.uint8). Avoid np.ones() here: it fills with the value 1, which is essentially black, not white.