Images as NumPy Arrays
An OpenCV image is a NumPy array with a specific contract: shape (height, width, channels), dtype uint8 for standard photographs, and - the detail that trips everyone first - BGR channel order, not RGB. Once you internalize these three facts, half of the "how do I do this" questions in OpenCV become plain array operations: splitting channels is a slice, a mask is a boolean or uint8 array, and a region of interest is a slice.
Reading an Image and Inspecting It
import cv2
import numpy as np
img = cv2.imread("photo.jpg")
print(type(img)) # numpy.ndarray
print(img.shape) # (height, width, 3) - note: height FIRST
print(img.dtype) # uint8 - values 0..255
print(img.ndim) # 3 for color, 2 for grayscale
# a missing file returns None, not an exception - always check
bad = cv2.imread("nope.jpg")
print(bad is None) # True - the classic silent failure
The shape order (height, width) is the opposite of what some plotting and ML conventions expect, and imread on a missing file gives you None rather than an error - both habits are the source of the most common beginner bugs. The defensive pattern is one line: img = cv2.imread(path); assert img is not None, path.
BGR: The Channel Order
# channel order in OpenCV: B, G, R - index 0 is BLUE
print(img[10, 20]) # e.g. [ 60 120 200] - that is (B, G, R)
# extracting a single channel
blue = img[:, :, 0]
green = img[:, :, 1]
red = img[:, :, 2]
# split into three arrays at once
b, g, r = cv2.split(img)
print(b.shape) # (h, w) - a 2-D array
# and back again
merged = cv2.merge([b, g, r])
print(np.array_equal(merged, img)) # True
When an image displays with odd colors, the cause is almost always BGR/RGB confusion: matplotlib expects RGB, so plt.imshow(cv2.imread(f)) swaps red and blue. The fix is cv2.cvtColor(img, cv2.COLOR_BGR2RGB) before plotting, or plt.imshow(img[:, :, ::-1]). Know the order at every boundary between OpenCV and another library.
Creating Blank Images
import cv2
import numpy as np
# shape is (height, width, channels)
black = np.zeros((120, 160, 3), dtype="uint8")
white = np.full((120, 160, 3), 255, dtype="uint8")
gray = np.full((120, 160), 128, dtype="uint8") # grayscale: no channel dim
# the OpenCV helpers
z = np.zeros((100, 100), dtype=np.uint8)
o = np.ones((100, 100), dtype=np.uint8) * 255
# a copy, because slices alias
roi = img[20:80, 40:120]
roi_copy = img[20:80, 40:120].copy()
roi[0, 0] = (255, 0, 0)
print(img[20, 40]) # changed - roi is a view
roi2 = img[20:80, 40:120].copy()
roi2[0, 0] = (0, 255, 0)
print(img[20, 40]) # unchanged - the copy absorbed the write
Blank canvases come from zeros, full, or ones with the right shape and dtype="uint8". Grayscale images have shape (h, w) - no channel axis - and mixing a 2-D gray array with a 3-D color array in an operation is a shape error waiting to happen. The copy discipline is the NumPy view/copy rule again: slicing an image gives a view, and only .copy() makes the region independent.
The uint8 Range
- Values are 0-255 per channel; arithmetic that overflows clips or wraps (uint8 wraps: 255 + 1 = 0).
- Use
cv2.add/cv2.subtractfor saturating arithmetic (clips at 0/255) instead of+/-when the math can exceed the range. - Internal computations (filters, DNNs) often use float32 - a
cvtColorto a float space or an explicitastype("float32")is the bridge. - Write back to disk after
astype("uint8")-imwriteof a float array does not do what you expect.
Summary
Treat every OpenCV image as a NumPy array with the contract: shape (height, width, channels), dtype uint8, channels in BGR order. Read defensively (imread returns None on failure), extract channels by slicing or cv2.split, convert at the matplotlib/ML boundary to avoid the color swap, and respect the uint8 range - saturating ops via cv2.add, float32 for internal math. With those rules, "how do I manipulate an image" is just "how do I manipulate an array," and you already know the answer.