Introduction: Extracting A Reusable Toolkit

Welcome to Unit 2 of our four-unit course on building a panorama stitcher! In the previous unit, we learned the basics of loading and inspecting image files. Now, as our project grows, we want to keep our main stitching logic clean and easy to read.

To achieve this, we are going to extract our common helper functions into a dedicated, reusable file called **cvkit.py**. Building a dedicated computer vision toolkit allows us to easily reuse code across our entire project. In this lesson, we will build a suite of tools to safely load, label, and perfectly stack multiple images side-by-side into a single visual "workbench" panel.

Standardizing Image Inputs

As a quick reminder from our previous unit, **OpenCV** treats images as multi-dimensional **NumPy** arrays. An image has dimensions for its height (rows), its width (columns), and its color channels (like Blue, Green, and Red). We already have a function called resize_long_edge provided in our toolkit to handle resizing, so let's focus on safely reading and standardizing our image files.

First, we want a reliable way to load our images. Let's create a function called read_color in our cvkit.py file.

import cv2

def read_color(path):
    image = cv2.imread(path, cv2.IMREAD_COLOR)

Here, we use cv2.imread to load the image from the given path and force it into a color format using the cv2.IMREAD_COLOR flag.

However, if we provide a bad file path, OpenCV does not automatically throw an error; instead, it simply returns None. This can cause confusing errors later in our program. Let's add a safety check to explicitly raise an error if the image fails to load.

import cv2

def read_color(path):
    image = cv2.imread(path, cv2.IMREAD_COLOR)
    if image is None:
        raise ValueError(f"Could not read image: {path}")
    return image

By explicitly raising a ValueError, we ensure our program fails gracefully and tells us exactly which file is missing.

Next, as we process images in future lessons, some of our operations will output 2-dimensional grayscale images (just height and width) instead of 3-dimensional color arrays. Because we want to stack our images side-by-side in our preview panel, they must all have the same number of dimensions. Let's build an as_bgr function to standardize this.

def as_bgr(image):
    if image.ndim == 2:
        return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
    return image.copy()

This function checks the number of dimensions in the image array using .ndim. If it is 2 (meaning it is a grayscale image), we use OpenCV's cvtColor to convert it to a 3-channel BGR image. If it already has 3 channels, we simply return a fresh copy of the image.

Adding Labels To Images

When we are inspecting multiple steps of our panorama pipeline, it is incredibly helpful to have text labels directly on the images. Let's build a label_image function to accomplish this.

First, we will ensure the image is in color so that our labels can be drawn consistently.

def label_image(image, text):
    output = as_bgr(image)

Next, to make sure our text is readable regardless of what is happening in the picture, we will draw a black rectangle in the top-left corner as a background for our text.

def label_image(image, text):
    output = as_bgr(image)
    label_width = min(output.shape[1], max(220, 12 * len(text)))
    cv2.rectangle(output, (0, 0), (label_width, 34), (0, 0, 0), -1)

In this code, we dynamically calculate the label_width so that it is wide enough to fit our text, but not wider than the image itself (output.shape[1]). Then, we use cv2.rectangle to draw a solid black box. The coordinates (0, 0) represent the top-left corner, and (label_width, 34) represent the bottom-right corner of our rectangle. The (0, 0, 0) sets the color to black, and -1 tells OpenCV to fill the rectangle completely solid.

Finally, we will stamp our text in white over the black background.

def label_image(image, text):
    output = as_bgr(image)
    label_width = min(output.shape[1], max(220, 12 * len(text)))
    cv2.rectangle(output, (0, 0), (label_width, 34), (0, 0, 0), -1)
    cv2.putText(
        output,
        text,
        (10, 23),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.65,
        (255, 255, 255),
        2,
    )
    return output

The cv2.putText function places our text starting at the coordinates (10, 23) inside our black box. We use a simple font (cv2.FONT_HERSHEY_SIMPLEX), scale the text to 0.65, set the color to pure white (255, 255, 255), and set the line thickness to 2.

Padding And Stacking The Preview Panel

To view our images side-by-side, we will use a NumPy function called np.hstack (horizontal stack). However, np.hstack has a strict rule: all image arrays provided to it must have the exact same height.

If we have one tall image and one short image, we cannot simply stretch or crop the short image, as that would destroy the data we are trying to inspect! Instead, we will build a pad_to_height function that adds a blank black space to the bottom of shorter images.

First, we check if the image even needs padding.

import numpy as np

def pad_to_height(image, height):
    if image.shape[0] == height:
        return image

Here, image.shape[0] gives us the height in pixels. If it already matches the target height, we do nothing and return the image.

If the image is shorter, we need to create a new, blank black canvas that is the correct size.

import numpy as np

def pad_to_height(image, height):
    if image.shape[0] == height:
        return image

    output = np.zeros((height, image.shape[1], 3), dtype=image.dtype)

We use np.zeros to create an array filled with black pixels. It takes a tuple of our desired dimensions: the target height, the original width (image.shape[1]), and 3 color channels. We also ensure it shares the exact same data type (dtype) as the original image.

Finally, we paste our original image directly onto the top of this black canvas.

import numpy as np

def pad_to_height(image, height):
    if image.shape[0] == height:
        return image

    output = np.zeros((height, image.shape[1], 3), dtype=image.dtype)
    output[: image.shape[0], : image.shape[1]] = image
    return output

Using Python array slicing, output[: image.shape[0], : image.shape[1]] selects a region in the top-left of the canvas that perfectly matches the dimensions of our original image. By assigning image to this region, the original picture sits at the top, and the extra height at the bottom remains black.

Now we can combine everything into our make_panel function. This function takes a list of items (which are pairs of text names and images) and a max_size for resizing.

def make_panel(items, max_size=900):
    if not items:
        raise ValueError("make_panel requires at least one image")

    tiles = []
    for name, image in items:
        labeled = label_image(image, name)
        tiles.append(resize_long_edge(labeled, max_size=max_size))

We loop through our items, apply our label_image function to give them a title, and then use our provided resize_long_edge function to scale them down so they fit nicely on our screen. We store these prepared images in a list called tiles.

Lastly, we find the tallest image in our list, pad all the images to match that height, and stack them.

def make_panel(items, max_size=900):
    if not items:
        raise ValueError("make_panel requires at least one image")

    tiles = []
    for name, image in items:
        labeled = label_image(image, name)
        tiles.append(resize_long_edge(labeled, max_size=max_size))

    max_height = max(tile.shape[0] for tile in tiles)
    tiles = [pad_to_height(tile, max_height) for tile in tiles]
    return np.hstack(tiles)

We use max() to find the largest height among all our tiles. Then, we apply our pad_to_height function to every tile. Now that they are all the exact same height, np.hstack(tiles) safely stitches them together into one large preview image.

A Cleaner Main Script

Because we extracted all of this logic into cvkit.py, our main script (**solution.py**) becomes beautifully simple.

Let's set up our main script to accept a file path from the command line and import our new custom tools.

import argparse
import cv2

from cvkit import make_panel, read_color

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("path")
    parser.add_argument("--preview-size", type=int, default=900)
    args = parser.parse_args()

Notice that we import make_panel and read_color directly from our cvkit module. Next, we will use them to read our image file. Let's prove our toolkit works by creating a panel that displays two identical images side-by-side.

    image = read_color(args.path)
    
    # We pass a list containing two labeled images to our panel maker
    items_to_display = [("original", image), ("duplicate", image)]
    panel = make_panel(items_to_display, max_size=args.preview_size)

By passing a list of two items, our make_panel function will label the first one original and the second one duplicate, resize them both, and safely stack them horizontally.

Finally, we display the generated panel on the screen.

    cv2.imshow("workbench", panel)
    cv2.waitKey(0)
    cv2.destroyAllWindows()

if __name__ == "__main__":
    main()

When this script runs, it opens a window called workbench. Inside, you will see your image displayed twice, side-by-side, each with a neat black-and-white label in the top-left corner. If you resize the window or pass images of different sizes in the future, the toolkit handles all the messy dimensional math for you.

Summary And Next Steps

In this lesson, we significantly leveled up our project structure by moving our reusable code into a dedicated module (cvkit.py). We standardized our image dimensions to safely handle BGR vs grayscale inputs, and we used NumPy array slicing to pad smaller images with black space, ensuring we never lose data or crash when stacking images side-by-side.

Now it is your turn to build out these tools! In the upcoming practice exercises, you will write cvkit.py piece by piece — from handling array standardization to writing the panel builder — until you have a fully functioning visual workbench. Let's head over to the exercises and put this toolkit together!

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal