Where this fits in the series

In part 2 you saw how a camera turns light into a grid of numbers, and in part 3 you saw how those numbers can be organised into colour spaces such as RGB, HSV and greyscale. Everything so far has been about ideas. This part is about tools: it is where you actually open Python, load a picture, look at it, change it a little, and save it again. Every later part of this path, from blurring and edge detection to building a document scanner, starts from the same three operations covered here: reading an image into your program, showing it on screen so you can check what you are doing, and saving the result. Get comfortable with these three and the rest of the path becomes much easier to follow.

What OpenCV is, in one paragraph

OpenCV (Open Source Computer Vision Library) is a free, widely used library of functions for working with images and video: reading files, resizing, blurring, detecting edges, finding shapes, tracking objects, and much more. The core of OpenCV is written in C++ for speed, and the opencv-python package gives you a Python interface to it, so when you call a function like cv2.imread, you are getting the performance of compiled C++ code with the convenience of writing a few lines of Python. It is used across robotics, manufacturing inspection, mobile apps and research, which is part of why it is a sensible place to build your practical computer vision skills, alongside the examples of everyday computer vision you saw in part 1.

Installing OpenCV

OpenCV is not part of the Python standard library, so you need to install it first. The package name on PyPI is opencv-python, and it is imported in code as cv2 (a name left over from an older version of the library, version 2, that has stuck ever since). This article assumes OpenCV 4.x and a recent version of NumPy; the functions covered here (imread, imshow, imwrite, cvtColor) have been stable across OpenCV 4 releases, so you do not need to worry about pinning an exact minor version for this material.

💻Bash
pip install opencv-python numpy

There are a few related packages worth knowing about so you do not get confused later. opencv-contrib-python includes extra, less common modules on top of the standard ones; you do not need it for anything in this path. opencv-python-headless is a version built without any graphical window support, which is common on servers or in some hosted notebook environments; it installs fine and does everything except open the native image windows described later in this article. Once installed, you can confirm everything works with a two-line check.

🐍Python
import cv2
print(cv2.__version__)

This should print a version string starting with 4, such as 4.9.0 or similar, depending on when you installed it. If the import fails, double check that you installed opencv-python (not a package simply called opencv, which does not exist on PyPI) in the same Python environment you are running your script from.

A sample image to work with

Rather than asking you to download a specific picture from a link that might move or break, we will generate a small test image directly in Python using NumPy and OpenCV's own drawing functions. This has two advantages: it needs no internet connection, and it gives you an image where you know exactly what every pixel should be, which makes it much easier to check that your code is doing what you think it is doing. Everything you learn here applies identically to a photo from your phone or a frame from your webcam.

🐍Python
import cv2
import numpy as np

# A blank canvas: 300 rows (height), 400 columns (width), 3 colour channels
canvas = np.zeros((300, 400, 3), dtype=np.uint8)

# Fill the whole canvas with a light grey background.
# Remember OpenCV's channel order is Blue, Green, Red (more on this below),
# so (200, 200, 200) is grey regardless of order since all values are equal.
canvas[:] = (200, 200, 200)

# A filled red rectangle. In BGR, red is (0, 0, 255).
cv2.rectangle(canvas, (30, 30), (180, 150), (0, 0, 255), thickness=-1)

# A filled green circle. In BGR, green is (0, 255, 0).
cv2.circle(canvas, (300, 90), 60, (0, 255, 0), thickness=-1)

# Some blue text. In BGR, blue is (255, 0, 0).
cv2.putText(canvas, "OpenCV", (120, 250), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (255, 0, 0), 2)

cv2.imwrite("sample.png", canvas)
print("Saved sample.png")

Walking through this: np.zeros((300, 400, 3), dtype=np.uint8) creates an array of 300 by 400 by 3 zeros, which is a black image of height 300 pixels and width 400 pixels with three colour channels. canvas[:] = (200, 200, 200) uses NumPy slicing to set every pixel in the whole array to that grey value in one line, which is much faster than looping over pixels yourself. The drawing functions, cv2.rectangle, cv2.circle and cv2.putText, all take the image to draw on, some coordinates, a colour tuple and a thickness; passing thickness=-1 tells OpenCV to fill the shape rather than just outline it. Finally cv2.imwrite writes the array to a file called sample.png in your current working directory. Run this once and you will have a small, colourful test image to use for the rest of this article.

Reading an image: cv2.imread

To load an image file into a program, you use cv2.imread, giving it a file path and, optionally, a flag that controls how the image is read.

🐍Python
import cv2

img_color = cv2.imread("sample.png", cv2.IMREAD_COLOR)
img_gray = cv2.imread("sample.png", cv2.IMREAD_GRAYSCALE)
img_unchanged = cv2.imread("sample.png", cv2.IMREAD_UNCHANGED)

print("Color image shape:", img_color.shape)
print("Grayscale image shape:", img_gray.shape)
print("Unchanged image shape:", img_unchanged.shape)

missing = cv2.imread("does_not_exist.png")
print("Missing file result:", missing)

With the sample image from the previous section, this prints shapes of (300, 400, 3) for the colour version, (300, 400) for the greyscale version (no third dimension, because there is only one value per pixel), and (300, 400, 3) again for the unchanged version, since our PNG has no transparency channel. cv2.IMREAD_COLOR is the default if you give no flag at all, and it always returns a three-channel image, converting greyscale source files to three identical channels if needed. cv2.IMREAD_GRAYSCALE converts to a single channel on load, using the same ideas you met in part 3 for turning colour into greyscale. cv2.IMREAD_UNCHANGED preserves whatever the file actually contains, including a fourth alpha (transparency) channel if the file has one.

The last line is the one to pay close attention to. OpenCV does not raise an error when it cannot find or read a file; it simply returns None. If you then try to use that result, for example by checking its shape, Python will fail a few lines later with an error such as AttributeError: 'NoneType' object has no attribute 'shape', which can be confusing because the error appears far from its real cause: a wrong file path. The fix is a habit, not a clever trick: always check the result of imread before using it.

🐍Python
import cv2

img = cv2.imread("sample.png")
if img is None:
    raise FileNotFoundError("Could not read sample.png - check the path and that the file exists")
print("Loaded image with shape", img.shape)

What's actually inside: shape, dtype and indexing

As part 2 and part 3 explained, an image is a grid of numbers. In OpenCV that grid is a NumPy array, and three things about it matter constantly: its shape, its dtype, and how you index into it. The shape of a colour image is (height, width, channels), for example (300, 400, 3): 300 rows of pixels stacked vertically, 400 columns of pixels across, and 3 numbers per pixel. Note the order: height comes before width, which is the opposite of how people often say "the image is 400 by 300" meaning width by height. Mixing these up is an easy way to introduce bugs when you resize or crop images later in the path. The dtype is almost always uint8, an unsigned 8-bit integer, meaning each number in the array can only be a whole number from 0 to 255; this matches what part 2 described about a sensor's limited range of brightness levels per channel.

🐍Python
import cv2

img = cv2.imread("sample.png")

# Index a single pixel: img[row, col]
print("Pixel at row 10, col 10:", img[10, 10])

# Slice out a rectangular region: rows 30 to 150, columns 30 to 180
region = img[30:150, 30:180]
print("Region shape:", region.shape)

# Paint a small white square in the top-left corner by assigning to a slice
img[0:20, 0:20] = (255, 255, 255)
cv2.imwrite("sample_marked.png", img)

img[10, 10] returns an array of three numbers, the pixel's blue, green and red values, because that background area is grey, something close to [200 200 200]. img[30:150, 30:180] uses NumPy slicing to cut out a rectangular block of the image: rows 30 up to (but not including) 150, and columns 30 up to 180. This is exactly the kind of cropping you will use repeatedly in the hands-on projects later in the path. The row index moves the position down the image (it corresponds to the vertical, or y, direction) and the column index moves across the image (the horizontal, or x, direction); OpenCV's own drawing functions, confusingly, expect coordinates the other way round, as (x, y), which is why the rectangle call earlier used (30, 30) and (180, 150) while this slicing uses [30:150, 30:180]. Keeping straight which order a given function expects is one of the small but persistent sources of bugs in OpenCV code, so when something looks flipped or cropped in the wrong place, this mismatch is often the reason.

One more detail worth knowing now, because it will matter once you start writing more complex pipelines: a slice like img[0:20, 0:20] is a view into the original array, not a separate copy. Assigning to it, as in the example above, changes the original img array in place. If you want to experiment on a region without affecting the original image, make an explicit copy first with img.copy().

The BGR surprise

Part 3 described the RGB colour space as three channels, red, green and blue, in that order. OpenCV, for historical reasons that do not matter much in practice, stores colour images with the channels in the opposite order: blue first, then green, then red, usually written BGR. This is not a bug and not something you can configure away; it is simply how cv2.imread, cv2.imshow and cv2.imwrite all expect colour data to be arranged. The practical consequence is that any time you hand an OpenCV image to a tool that expects standard RGB, such as matplotlib or most web and mobile image code, you need to swap the channel order first, or your reds and blues will appear swapped in the result.

🐍Python
import cv2

img = cv2.imread("sample.png")

# The rectangle we drew is red, and sits around row 90, column 90
bgr_pixel = img[90, 90]
print("BGR pixel:", bgr_pixel)   # roughly [  0   0 255]

img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
rgb_pixel = img_rgb[90, 90]
print("RGB pixel:", rgb_pixel)  # roughly [255   0   0]

Both prints describe the exact same physical colour, red. The first array, [0, 0, 255], is correct when read as (blue, green, red): no blue, no green, full red. The second array, [255, 0, 0], is correct when read as (red, green, blue): full red, no green, no blue. The numbers changed because cv2.cvtColor with the COLOR_BGR2RGB flag physically reordered the three values in memory. This single conversion function, cvtColor, is also the one you already met conceptually in part 3 for turning images between RGB, HSV and greyscale; here it is simply doing a channel reorder rather than a colour space change.

This BGR versus RGB mix-up is probably the single most common beginner mistake in OpenCV code, and it is a sneaky one, because the image still looks almost right: shapes, edges and brightness are all correct, only the colours are swapped, so a photo of a red apple on green grass comes out looking like a blue apple on pink grass. If your colours ever look wrong in a way that otherwise makes no sense, check whether you forgot a BGR to RGB conversion before handing the image to a tool that expects RGB.

Showing an image: cv2.imshow

To display an image in its own window while running a normal Python script (not a notebook), use cv2.imshow, followed by cv2.waitKey and cv2.destroyAllWindows.

🐍Python
import cv2

img = cv2.imread("sample.png")
if img is None:
    raise FileNotFoundError("sample.png not found")

cv2.imshow("Sample image", img)
cv2.waitKey(0)
cv2.destroyAllWindows()

cv2.imshow("Sample image", img) opens a window with that title and draws the array in it, using BGR order directly, which is why you do not need any colour conversion here. cv2.waitKey(0) is the part beginners most often forget, and it is not optional: it pauses your program and waits for a key press, which also gives the operating system time to actually draw the window. Without it, the window can appear blank, frozen, or not appear at all, because the script moves straight on to the next line and the window never gets a chance to render. The argument to waitKey is a number of milliseconds to wait; 0 means wait forever until a key is pressed, while a small number such as 1 is used inside loops when processing video, which part 8 covers in detail. cv2.destroyAllWindows() closes all the windows OpenCV opened; without it, windows can sometimes linger after your script finishes.

Two practical notes. First, cv2.imshow opens a native operating system window, so it only works when you run your script locally with a display available; it does not work inside a hosted notebook such as Google Colab, and it will raise an error if you installed the opencv-python-headless package, which has no window support at all. Second, if you run a script with cv2.imshow from a terminal and the window does not seem to close, make sure a window is focused before pressing a key, since waitKey listens for key presses sent to an OpenCV window specifically.

Showing an image inside a notebook

If you are working in Jupyter or a similar notebook environment, the usual approach is to use matplotlib instead of cv2.imshow. Since matplotlib expects RGB order, you need the conversion from the BGR section above.

🐍Python
import cv2
import matplotlib.pyplot as plt

img = cv2.imread("sample.png")
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

plt.imshow(img_rgb)
plt.axis("off")
plt.show()

This draws the sample image with correct colours: a red rectangle, a green circle and blue text, matching what you specified when you created it. If you skip the cv2.cvtColor call and pass the original BGR array straight to plt.imshow, matplotlib will still draw something, but it will interpret the first channel as red and the last as blue, so the rectangle will appear blue and the text will appear red: a direct, visible demonstration of the BGR versus RGB mix-up described earlier.

Saving an image: cv2.imwrite

Saving an array back to a file uses cv2.imwrite, which takes a destination path and the image array. The file format is chosen from the file extension you give it, so sample.png is saved as PNG and sample.jpg as JPEG, with no extra argument needed to pick the format.

🐍Python
import cv2

img = cv2.imread("sample.png")

# PNG is lossless: every pixel value is preserved exactly
ok1 = cv2.imwrite("sample_copy.png", img)

# JPEG is lossy; you can control the trade-off between file size and quality
# with a quality value from 0 (smallest file, worst quality) to 100 (best quality)
ok2 = cv2.imwrite("sample.jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 90])

print("PNG saved:", ok1)
print("JPEG saved:", ok2)

PNG is a lossless format, meaning the pixel values you saved are exactly the ones you will get back when you read the file again, which is useful while you are developing and testing code. JPEG is a lossy format: it compresses the image by discarding some detail, trading a smaller file size for a loss in exact pixel accuracy, controlled here by the IMWRITE_JPEG_QUALITY parameter from 0 to 100. Unlike imread, which returns None on failure, imwrite returns a boolean: True if the save worked and False if it did not, without raising an exception. A common and quiet source of bugs is saving to a folder that does not exist, for example cv2.imwrite("outputs/sample.png", img) when the outputs folder has not been created; OpenCV will not create the folder for you, and imwrite will simply return False. Get into the habit of checking that returned value, especially in scripts that save many files, such as the document scanner and coin counting projects later in this path.

Putting it together

Here is a short script that uses everything covered so far: reading an image, converting it between colour representations using the ideas from part 3, saving the results, and displaying several versions side by side with matplotlib.

🐍Python
import cv2
import matplotlib.pyplot as plt

img_bgr = cv2.imread("sample.png")
if img_bgr is None:
    raise FileNotFoundError("sample.png not found")

img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
img_gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
img_hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)

cv2.imwrite("sample_gray.png", img_gray)
cv2.imwrite("sample_hsv.png", img_hsv)

fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].imshow(img_rgb)
axes[0].set_title("Colour (converted to RGB for display)")
axes[1].imshow(img_gray, cmap="gray")
axes[1].set_title("Greyscale")
axes[2].imshow(img_hsv)
axes[2].set_title("HSV array (not meant for direct viewing)")
for ax in axes:
    ax.axis("off")
plt.tight_layout()
plt.show()

The first and second panels should look exactly as you would expect: a correctly coloured version of the sample image, and a greyscale version where the red rectangle, green circle and blue text have all become different shades of grey, following the greyscale weighting discussed in part 3. The third panel is worth pausing on. cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) produces an array of the same shape as the original, but its three numbers per pixel are hue, saturation and value, not blue, green and red. Handing that array straight to plt.imshow, which assumes red, green, blue, produces a picture with odd, shifted colours that do not match the original at all. This is expected and not a bug: an HSV array is meant to be processed (for example, to pick out all the "red" pixels regardless of lighting, which part 7 will cover when you build masks), not displayed directly as if it were a normal photograph.

A quick look at the webcam

Working with a live webcam in any depth, including real-time loops and motion detection, is the subject of part 8. But it is worth seeing now that capturing a single frame from a webcam gives you an array that is identical in every way to one loaded with cv2.imread: same shape, same uint8 values, same BGR channel order. Everything you have learned in this article about shapes, indexing, colour order, displaying and saving applies without any changes.

🐍Python
import cv2

cap = cv2.VideoCapture(0)
if not cap.isOpened():
    raise RuntimeError("Could not open the webcam. Check it is connected and not in use by another app.")

ret, frame = cap.read()
cap.release()

if ret:
    print("Captured a frame with shape", frame.shape)
    cv2.imwrite("webcam_snapshot.png", frame)
else:
    print("Could not read a frame from the webcam")

cv2.VideoCapture(0) opens the default camera device (0 is usually the built-in webcam on a laptop; a different number may be needed if you have more than one camera attached). cap.isOpened() checks whether the connection actually succeeded, which can fail if the camera is in use by another application or does not exist. cap.read() returns two things: a boolean ret telling you whether a frame was captured successfully, and frame, the image itself as a NumPy array in BGR order, exactly like the images you have been working with throughout this article. Calling cap.release() afterwards is important: it frees the camera so other programs, including a second run of your own script, can use it.

Common mistakes

  • Not checking for None after cv2.imread: a wrong or misspelled file path fails silently, and the real error only shows up several lines later as a confusing AttributeError.
  • Forgetting cv2.waitKey after cv2.imshow: without it the window may not render at all, or may appear to freeze.
  • Forgetting cv2.destroyAllWindows: windows can linger open after your script has otherwise finished running.
  • Mixing up BGR and RGB: passing an OpenCV image straight to matplotlib, PIL, or most web and mobile code without converting with cv2.cvtColor(img, cv2.COLOR_BGR2RGB) first, which swaps red and blue in the displayed result.
  • Not checking the boolean return value of cv2.imwrite: saving to a folder that does not exist returns False without raising an exception, so the failure can go unnoticed.
  • Confusing shape order: an image's shape is (height, width, channels), the opposite of the common "width by height" way of describing a picture's size.
  • Confusing coordinate order between NumPy slicing, which uses [row, column] i.e. [y, x], and OpenCV drawing functions like cv2.rectangle and cv2.circle, which take points as (x, y).
  • Editing a slice of an array and being surprised the original image changed too: a slice is a view, not a copy, unless you explicitly call .copy().

Summary and what's next

An image in OpenCV is a NumPy array of uint8 numbers with shape (height, width, channels), built on exactly the pixel and colour ideas covered in parts 2 and 3. You read one from disk with cv2.imread, always checking for None in case the path was wrong; you display one with cv2.imshow followed by cv2.waitKey and cv2.destroyAllWindows in a normal script, or with matplotlib's plt.imshow (after converting BGR to RGB) inside a notebook; and you save one with cv2.imwrite, checking its boolean return value rather than assuming it worked. The one fact worth burning into memory above all others is that OpenCV stores colours as blue, green, red, not red, green, blue, and that this single detail is behind a large share of beginner bugs involving swapped colours.

With reading, showing and saving images now familiar, part 5 moves on to actually changing the content of an image: filters and blurs that clean up noise and smooth out detail, the first step toward the edge detection, contours and masking techniques used in the hands-on projects later in this path.