Sitemap

Image Processing in Python: Pixels, NumPy Arrays and OpenCV from Scratch

10 min read6 days ago

--

What I learned about how computers “see” images, and how to build a small object-counting project with OpenCV.

Press enter or click to view image in full size

When I first opened a photo in Python, I expected something complicated. It turned out to be just a grid of numbers. Once that idea clicked, everything else in image processing, from converting to grayscale to detecting edges, started to make sense.

In this article I walk through the concepts from our class: what digital images are, what pixels and channels mean, how NumPy arrays hold image data, what OpenCV is, the basic functions I use most, and finally a step-by-step mini project that counts objects in a photo.

1. Images: how a computer stores a picture

To us, a photo is a scene. To a computer, it is a rectangular grid of tiny coloured dots, and each dot is stored as a number (or a few numbers). There are two broad families of images:

  • Raster images (JPG, PNG, BMP) store a fixed grid of pixels. Zoom in far enough and you see little squares. OpenCV works with these.
  • Vector images (SVG) store shapes and equations instead, so they scale without getting blurry.

Everything in this article is about raster images, because that is what cameras produce and what computer vision models consume.

2. Pixels: the smallest unit

A pixel (picture element) is one cell of that grid. It stores how bright, or what colour, that spot is. Three things describe a pixel-based image:

  • Dimensions: width x height, for example 600 x 400 pixels. More pixels means more detail and a bigger file.
  • Pixel value: for the common 8-bit image, each value is an integer from 0 to 255. In grayscale, 0 is pure black, 255 is pure white, and numbers in between are shades of grey.
  • Channels: a colour pixel stores more than one value, one per colour channel (more on this in section 4).

The picture below zooms into a tiny 8 x 8 block of a photo. Each square is one pixel and the number printed on it is its brightness.

Press enter or click to view image in full size

Zooming in on an 8 x 8 block of a grayscale image reveals the raw numbers behind the picture.

3. Arrays: images as NumPy data

This is the key idea: an image is just a NumPy array. When OpenCV loads a picture, it hands you a numpy.ndarray, so everything you know about NumPy (indexing, slicing, maths on whole arrays) works on images directly.

  • A grayscale image is a 2D array with shape (height, width).
  • A colour image is a 3D array with shape (height, width, channels).

Here is what that looks like in code:

import cv2
img = cv2.imread("sample.jpg")print(type(img))     # <class 'numpy.ndarray'>
print(img.shape) # (400, 600, 3) -> 400 rows, 600 columns, 3 channels
print(img.dtype) # uint8 -> unsigned 8-bit integers, 0 to 255
print(img.size) # 720000 -> 400 * 600 * 3 values in total
print(img[100, 200]) # [ 87 142 199] -> the [B, G, R] values of one pixel
Press enter or click to view image in full size

A colour image is a stack of three 2D arrays, one per channel

Two small details trip up almost everyone at first:

  1. Indexing is [row, column], which means [y, x], not [x, y].
  2. img.shape gives height first, then width. But OpenCV functions like cv2.resize take (width, height). Mixing these up is a classic bug.

Because an image is an array, editing pixels is just array assignment:

img[100, 200] = [255, 255, 255]   # make one pixel white
img[0:50, 0:50] = [0, 0, 255] # paint a 50x50 red square (BGR!) at the top-left

NumPy is what makes image processing fast. Instead of looping over 240,000 pixels in Python, operations like img + 50 (brighten) or img[:, ::-1] (mirror) run on the whole array at once in optimised C code.

4. Channels: RGB, BGR and grayscale

A channel is one layer of colour information. Most colour images use three channels: Red, Green and Blue. Each pixel has one value per channel, and mixing the three produces every colour on screen: (255, 0, 0) is red, (0, 255, 0) is green, (255, 255, 255) is white, (0, 0, 0) is black.

Important gotcha: OpenCV stores channels in BGR order (Blue, Green, Red), not RGB. Libraries like Matplotlib and PIL expect RGB, so if you display an OpenCV image with Matplotlib without converting, the colours look wrong (oranges turn blue).

Press enter or click to view image in full size
Left: BGR data shown as if it were RGB. Middle: the correct image after conversion. Right: the same image in HSV colour space.

You can split an image into its channels and look at each one on its own:

Left: BGR data shown as if it were RGB. Middle: the correct image after conversion. Right: the same image in HSV colour space.

You can split an image into its channels and look at each one on its own:

b, g, r = cv2.split(img) # three 2D arrays
merged = cv2.merge([b, g, r]) # put them back togetherb, g, r = cv2.split(img) # three 2D arrays
merged = cv2.merge([b, g, r]) # put them back together
Press enter or click to view image in full size

Each channel on its own is a grayscale picture: bright areas mean “a lot of this colour is here”.

Grayscale has just one channel. It throws away colour and keeps brightness only, which makes images smaller (one third of the data) and faster to process. A lot of algorithms, such as edge detection and thresholding, work on grayscale because shape and brightness matter more than colour.

gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
print(gray.shape) # (400, 600): the channel dimension is gone

OpenCV computes grayscale as a weighted sum, roughly 0.299*R + 0.587*G + 0.114*B, because our eyes are most sensitive to green and least to blue.

Each channel on its own is a grayscale picture: bright areas mean “a lot of this colour is here”.

Press enter or click to view image in full size

A histogram counts how many pixels exist at each brightness level. It is a quick way to see if an image is dark, bright or washed out

5. OpenCV: why it matters

OpenCV (Open Source Computer Vision Library) is a free, open-source library for working with images and video. It was started at Intel in the late 1990s (first public release in 2000) and is written in C++ with bindings for Python, Java and others. In Python you install it with:

pip install opencv-python

and import it as cv2. It matters because:

  • It gives you hundreds of ready-made, optimised functions: filtering, edge detection, face detection, object tracking, camera calibration and more.
  • It is fast, because the heavy work runs in C++ while you write simple Python.
  • It plays well with NumPy, Matplotlib and machine learning tools such as TensorFlow and PyTorch, which usually need OpenCV for data preprocessing.
  • It is used in real products: medical imaging, self-driving cars, quality inspection in factories, document scanning, face unlock and more.

6. Basic OpenCV functions, with syntax and examples

Reading, showing and saving

img = cv2.imread("sample.jpg")             # read (returns None if the path is wrong!)
cv2.imshow("My Image", img) # open a window
cv2.waitKey(0) # wait for any key press (0 = forever)
cv2.destroyAllWindows() # close all windows
cv2.imwrite("output.png", img) # save to disk

Always check that imread actually found the file: if img is None: print("Check the file path").

Get Guru’s stories in your inbox

Join Medium for free to get updates from this writer.

On Google Colab or Jupyter, cv2.imshow does not work. Use this instead:

from google.colab.patches import cv2_imshow
cv2_imshow(img)

Or use Matplotlib, remembering the BGR to RGB conversion:

import matplotlib.pyplot as plt
plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
plt.axis("off")
plt.show()

Resizing, cropping, flipping and rotating

h, w = img.shape[:2]
resized = cv2.resize(img, (300, 200))          # (width, height)
cropped = img[100:300, 150:450] # NumPy slicing: [y1:y2, x1:x2]
flipped = cv2.flip(img, 1) # 1 = horizontal, 0 = vertical, -1 = both
M = cv2.getRotationMatrix2D((w // 2, h // 2), 30, 1.0) # centre, angle, scale
rotated = cv2.warpAffine(img, M, (w, h))

Cropping is just array slicing, no special function needed.

Drawing on images

canvas = img.copy()   # copy so the original stays untouched
cv2.rectangle(canvas, (30, 30), (230, 150), (0, 255, 0), 3) # top-left, bottom-right, colour (BGR), thickness
cv2.circle(canvas, (450, 120), 60, (0, 0, 255), 4) # centre, radius, colour, thickness
cv2.line(canvas, (30, 380), (570, 300), (255, 0, 0), 4) # start, end, colour, thickness
cv2.putText(canvas, "OpenCV", (200, 220), cv2.FONT_HERSHEY_SIMPLEX, 2, (255, 255, 255), 4, cv2.LINE_AA)
Press enter or click to view image in full size

Remember that colours are BGR: (0, 255, 0) is green and (0, 0, 255) is red.

Blurring, edge detection and thresholding

blur = cv2.GaussianBlur(img, (11, 11), 0)           # kernel size must be odd
edges = cv2.Canny(gray, 100, 200) # low and high thresholds
_, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
  • Gaussian blur smooths the image and reduces noise. A bigger kernel gives a stronger blur.
  • Canny finds edges, which are places where brightness changes sharply.
  • Thresholding turns a grayscale image into pure black and white: pixels above 127 become 255 and the rest become 0. It is the simplest way to separate an object from its background.
Press enter or click to view image in full size

Blur, edge detection and thresholding on the same image.

7. Building a mini project: counting objects in an image

Now let us put the pieces together. The goal: take a photo of coins on a dark surface and automatically count them, drawing a numbered box around each one. This uses nearly every concept above.

Blur, edge detection and thresholding on the same image.

Press enter or click to view image in full size

Step 1: Load and inspect

Always begin by loading the image and checking its shape, type and channels. This catches wrong file paths early.

img = cv2.imread("coins.png")
if img is None:
raise FileNotFoundError("Could not read image")
print(img.shape, img.dtype)

Step 2: Preprocess

Convert to grayscale (colour does not help us find round shapes) and blur slightly to remove noise so that tiny texture details do not confuse later steps.

gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (7, 7), 0)

Step 3: Fix uneven lighting

This step is easy to skip, and skipping it breaks the result. The top-left corner of the photo is brighter than the rest, so thresholding treats that bright background as one giant object and merges some coins into it. The fix is to estimate the background with a very strong blur and subtract it, which evens out the lighting.

import numpy as np
background = cv2.GaussianBlur(gray, (0, 0), 40).astype(np.int16)
flat = np.clip(blur.astype(np.int16) - background + 128, 0, 255).astype(np.uint8)

(The int16 conversion matters. With uint8, subtracting would wrap around instead of going negative.)

Step 4: Segment with a threshold

Instead of guessing a threshold, Otsu’s method picks the best one automatically by analysing the histogram.

_, binary = cv2.threshold(flat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

Step 5: Clean up with morphology

Morphological opening (erode, then dilate) removes small white specks and thin bridges connecting nearby shapes.

kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 13))
clean = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)

Step 6: Find the objects

A contour is the outline of a white blob. findContours returns one for each object, and we ignore tiny ones by area.

contours, _ = cv2.findContours(clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contours = [c for c in contours if cv2.contourArea(c) > 800]
print("Objects found:", len(contours))

Step 7: Annotate and save

result = img.copy()
for i, c in enumerate(contours, start=1):
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(result, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.putText(result, str(i), (x + 3, y + 16), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1)
cv2.putText(result, f"Objects: {len(contours)}", (8, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
cv2.imwrite("result.png", result)

Ideas to extend it

  • Measure each coin’s area and group coins by size.
  • Use cv2.VideoCapture(0) to run the same pipeline on a live webcam feed.
  • Replace the threshold step with a trained model for harder images.
  • Wrap it into a small web app with Flask or Streamlit.

Key takeaways

  • An image is a grid of pixels, and in Python that grid is a NumPy array.
  • Colour images have (height, width, 3) shape, grayscale images have (height, width).
  • OpenCV uses BGR, not RGB. Convert before showing with Matplotlib.
  • Cropping is slicing, drawing is a function call, and most analysis is a short chain of simple operations.
  • Real projects are mostly about preprocessing: grayscale, blur, fix lighting, threshold, clean up, then detect.

Image processing felt intimidating until I saw it as “arrays plus a handful of functions”. I hope this article makes it feel the same way for you. If you have questions or suggestions, drop a comment, and if it helped, a clap or share would mean a lot.

Happy coding!

Ideas to extend it

  • Measure each coin’s area and group coins by size.
  • Use cv2.VideoCapture(0) to run the same pipeline on a live webcam feed.
  • Replace the threshold step with a trained model for harder images.
  • Wrap it into a small web app with Flask or Streamlit.

Key takeaways

  • An image is a grid of pixels, and in Python that grid is a NumPy array.
  • Colour images have (height, width, 3) shape, grayscale images have (height, width).
  • OpenCV uses BGR, not RGB. Convert before showing with Matplotlib.
  • Cropping is slicing, drawing is a function call, and most analysis is a short chain of simple operations.
  • Real projects are mostly about preprocessing: grayscale, blur, fix lighting, threshold, clean up, then detect.

Image processing felt intimidating until I saw it as “arrays plus a handful of functions”. I hope this article makes it feel the same way for you. If you have questions or suggestions, drop a comment, and if it helped, a clap or share would mean a lot.

Happy coding!

--

--