Search…

OpenCV Setup + First 10 Tasks in Python

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

Reading about images only takes you so far. This post gets OpenCV running on your machine and then walks through ten tasks that cover most of what you will do in a real project. Every task has code you can run and an explanation of what the output means.

Prerequisites: Image fundamentals for colour spaces and histograms, and basic Python.

Install it properly

Use a virtual environment. Installing into the system Python is how you end up with three conflicting OpenCV builds and no idea which one is being imported.

python -m venv cv-env
source cv-env/bin/activate          # Windows: cv-env\Scripts\activate
pip install --upgrade pip
pip install opencv-python numpy matplotlib

Which package do you actually need?

# Package Includes Install when
1 opencv-python Core modules plus GUI windows Default choice for learning and desktop work
2 opencv-contrib-python Core plus extra modules (SIFT, tracking, ximgproc) You need SIFT, SURF, or the tracking API
3 opencv-python-headless Core, no GUI Docker images, servers, CI, anywhere without a display
4 opencv-contrib-python-headless Extra modules, no GUI Server deployment that also needs SIFT

The four OpenCV pip packages. Install exactly one.

Verify the install

import cv2
import numpy as np

print("OpenCV version:", cv2.__version__)
print("NumPy version :", np.__version__)

# make a test image so you do not need a file yet
img = np.zeros((200, 400, 3), dtype=np.uint8)
img[:] = (60, 120, 200)                       # BGR: a warm orange
cv2.putText(img, "OpenCV works", (40, 110), cv2.FONT_HERSHEY_SIMPLEX,
            1.0, (255, 255, 255), 2)
cv2.imwrite("check.png", img)
print("wrote check.png with shape", img.shape)

Open check.png. If it shows white text on an orange rectangle, you are set. If you want a live window instead:

cv2.imshow("test", img)
cv2.waitKey(0)          # blocks until a key is pressed
cv2.destroyAllWindows()
# Symptom Cause Fix
1 ModuleNotFoundError: cv2 Wrong environment active Activate the venv, reinstall inside it
2 imshow does nothing / window hangs Headless package installed Install the non-headless variant
3 cv2.SIFT_create missing Base package, not contrib Switch to opencv-contrib-python
4 imread returns None Wrong path, or unreadable file Print the absolute path and check os.path.exists
5 Colours look wrong in matplotlib BGR shown as RGB cv2.cvtColor(img, cv2.COLOR_BGR2RGB) first
6 Window closes instantly No waitKey call Add cv2.waitKey(0) after imshow

Every OpenCV setup problem you are likely to hit, and its one-line fix

The shape of every OpenCV script

Before the tasks, here is the pattern almost all of them follow.


Task 1: Load, inspect, and save

import cv2
import os

path = "photo.jpg"
img = cv2.imread(path)                 # BGR by default

if img is None:
    raise FileNotFoundError(f"could not read {os.path.abspath(path)}")

h, w, c = img.shape
print(f"size      : {w} x {h}")
print(f"channels  : {c}")
print(f"dtype     : {img.dtype}")
print(f"value range: {img.min()} to {img.max()}")
print(f"mean BGR  : {img.mean(axis=(0,1)).round(1)}")

cv2.imwrite("copy.png", img)

What the output tells you. If dtype is not uint8, something upstream converted your image and later operations will behave unexpectedly. If img.max() is well below 255, the image is under-exposed. If img.min() is 0 for thousands of pixels, shadows are clipped.

Those four printed numbers are a summary. The histogram is the full picture, and it costs one extra line:

import numpy as np

levels = np.arange(256)
for i, name in enumerate(["Blue", "Green", "Red"]):     # OpenCV order is BGR
    hist = cv2.calcHist([img], [i], None, [256], [0, 256]).ravel()
    mean = float(hist @ levels) / hist.sum()
    print(f"{name:5s} mean {mean:6.1f}"
          f"  clipped at 0: {int(hist[0]):6d}"
          f"  clipped at 255: {int(hist[255]):6d}")

Plotted, a per-channel histogram of an outdoor photo looks like this:

Make this a habit before you debug anything. Three channels stacked on top of each other means the photo is close to grey. One channel pinned against 255 means that channel is saturated and no white balance fix will recover it.

cv2.imread never raises on a missing file. It returns None, and the error surfaces three lines later as AttributeError: 'NoneType' object has no attribute 'shape'. Check explicitly.

Reading a file in grayscale directly is faster and uses a third of the memory:

gray = cv2.imread(path, cv2.IMREAD_GRAYSCALE)      # shape is (h, w), no channel axis

Task 2: Resize without distorting anything

Naive resizing squashes the image. Here is the arithmetic to avoid that.

A 1920×1080 frame needs to become at most 640 on the long side. The scale factor is:

s=6401920=0.3333s = \frac{640}{1920} = 0.3333

Apply it to both dimensions:

wnew=1920×0.3333=640,hnew=1080×0.3333=360w_{new} = 1920 \times 0.3333 = 640, \qquad h_{new} = 1080 \times 0.3333 = 360

To get a square 640×640 for a model, pad the short side. Total padding needed is 640360=280640 - 360 = 280, split evenly:

pad top=pad bottom=2802=140\text{pad top} = \text{pad bottom} = \frac{280}{2} = 140

Original (1×3)
1920
x
1080
Scaled by 0.333 (1×3)
640
x
360
Padded 140 top+bottom (1×3)
640
x
640
import cv2

def resize_and_pad(img, target=640, pad_value=(114, 114, 114)):
    h, w = img.shape[:2]
    scale = target / max(h, w)
    new_w, new_h = int(round(w * scale)), int(round(h * scale))

    interp = cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR
    resized = cv2.resize(img, (new_w, new_h), interpolation=interp)

    top = (target - new_h) // 2
    bottom = target - new_h - top
    left = (target - new_w) // 2
    right = target - new_w - left

    return cv2.copyMakeBorder(resized, top, bottom, left, right,
                              cv2.BORDER_CONSTANT, value=pad_value)

Two details in that function matter more than they look.

cv2.resize takes (width, height), not (height, width). This is the opposite order from img.shape. Getting it backwards on a non-square image raises an error immediately, which is lucky; on a square image it silently does nothing wrong and you never learn.

bottom is computed as a remainder, not as top again. When the padding is odd, say 281, top is 140 and bottom must be 141. Using top twice gives a 639-pixel image and an off-by-one bug that shows up much later.

Which interpolation method

INTER_NEARESTINTER_LINEARINTER_AREAINTER_CUBIC
How it worksCopy nearest pixelBlend 2x2 neighboursAverage over the source regionFit a curve over 4x4
Best forLabel masks, segmentation mapsGeneral enlargingShrinkingEnlarging when quality matters
SpeedFastestFastFastSlow
Creates new values?NoYesYesYes
Aliasing when shrinkingSevereNoticeableNoneNoticeable
Resize interpolation methods and when each one is correct

The rule that catches people: use INTER_NEAREST for label masks. A segmentation mask where class 3 is a road and class 4 is a car must never be blended, because averaging 3 and 4 produces 3.5, which is not a class. Any other method invents labels that do not exist.

For a video pipeline running at 30 frames per second, that difference is the gap between keeping up and dropping frames.


Task 3: Crop a region

Cropping is plain NumPy slicing, in [y1:y2, x1:x2] order.

h, w = img.shape[:2]

# centre crop, half size
y1, y2 = h // 4, 3 * h // 4
x1, x2 = w // 4, 3 * w // 4
centre = img[y1:y2, x1:x2]

print(centre.shape)          # (h/2, w/2, 3)

A safe crop clamps to the image bounds, because NumPy slicing silently returns a smaller array (or an empty one) instead of complaining:

def safe_crop(img, x, y, cw, ch):
    h, w = img.shape[:2]
    x1, y1 = max(0, x), max(0, y)
    x2, y2 = min(w, x + cw), min(h, y + ch)
    if x2 <= x1 or y2 <= y1:
        return None
    return img[y1:y2, x1:x2]

Slices are views, not copies. Writing to the crop writes to the original:

crop = img[100:200, 100:200]
crop[:] = 0             # this blanks that region of img too
safe = img[100:200, 100:200].copy()   # independent

Task 4: Convert between colour spaces

gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
hsv  = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
rgb  = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)     # for matplotlib
lab  = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)

Grayscale is not a plain average of the three channels. OpenCV uses the weights that match human brightness perception:

Y=0.299R+0.587G+0.114BY = 0.299 R + 0.587 G + 0.114 B

For a pure green pixel (R,G,B)=(0,255,0)(R, G, B) = (0, 255, 0):

Y=0.299(0)+0.587(255)+0.114(0)=149.7150Y = 0.299(0) + 0.587(255) + 0.114(0) = 149.7 \approx 150

For a pure blue pixel (0,0,255)(0, 0, 255):

Y=0.114(255)=29.129Y = 0.114(255) = 29.1 \approx 29

Green appears far brighter than blue to the eye, and the weights encode that. A plain average would give both 85, which would make blue text look as readable as green text in grayscale, and it does not.

# Colour B G R Grayscale (weighted) target Plain average
1 Pure red 0 0 255 76 85
2 Pure green 0 255 0 150 85
3 Pure blue 255 0 0 29 85
4 Yellow 0 255 255 226 170
5 White 255 255 255 255 255

Why the weighted formula matters: three colours that a plain average would make identical


Task 5: Draw shapes and text

Every drawing function modifies the image in place and takes coordinates as (x, y), the opposite of array indexing.

canvas = img.copy()                   # never draw on your source

cv2.rectangle(canvas, (50, 40), (220, 180), (0, 255, 0), 2)      # (x1,y1),(x2,y2)
cv2.circle(canvas, (300, 120), 40, (255, 0, 0), -1)              # -1 fills
cv2.line(canvas, (0, 0), (400, 300), (0, 0, 255), 1)
cv2.putText(canvas, "cap OK", (50, 30), cv2.FONT_HERSHEY_SIMPLEX,
            0.7, (255, 255, 255), 2, cv2.LINE_AA)

A label that stays readable on any background needs a filled box behind it. This helper is worth keeping:

def label_box(img, text, org, colour=(0, 255, 0)):
    (tw, th), base = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 1)
    x, y = org
    cv2.rectangle(img, (x, y - th - base - 4), (x + tw + 4, y), colour, -1)
    cv2.putText(img, text, (x + 2, y - base - 2), cv2.FONT_HERSHEY_SIMPLEX,
                0.6, (0, 0, 0), 1, cv2.LINE_AA)

cv2.LINE_AA turns on anti-aliasing. It costs almost nothing and makes overlays look far less rough.


Task 6: Blur and denoise

gauss  = cv2.GaussianBlur(gray, (0, 0), sigmaX=1.5)   # size derived from sigma
median = cv2.medianBlur(gray, 5)                      # size must be odd
bilat  = cv2.bilateralFilter(img, 9, 75, 75)          # slow, keeps edges

Passing (0, 0) for the kernel size lets OpenCV pick a size that matches your sigma, which is what you want. Sigma is the meaningful control; kernel size is a consequence of it.

Kernel sizes must be odd. cv2.medianBlur(gray, 4) raises an error, because an even window has no single middle pixel.

To choose a blur level, sweep it and measure:

import numpy as np

for sigma in [0.5, 1.0, 1.5, 2.0, 3.0]:
    b = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma)
    sharpness = cv2.Laplacian(b, cv2.CV_64F).var()
    print(f"sigma={sigma:>4}  sharpness={sharpness:8.1f}")

The variance of the Laplacian is a standard sharpness number. It drops as you blur more. Pick the largest sigma that still leaves your object detectable, and no larger.


Task 7: Threshold, fixed and automatic

_, fixed = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY)

otsu_val, otsu = cv2.threshold(gray, 0, 255,
                               cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print("Otsu picked:", otsu_val)

adaptive = cv2.adaptiveThreshold(gray, 255,
                                 cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                 cv2.THRESH_BINARY, blockSize=31, C=5)

Otsu's method An automatic threshold that tries every possible cutoff and picks the one that best separates the histogram into two groups. It works well when the histogram has two clear peaks. replaces the magic number 128 with a value derived from the image itself. Print otsu_val across a batch of images: if it jumps around wildly, your lighting is unstable and no single global threshold will hold.

Adaptive thresholding computes a different cutoff for each small region, which handles a page lit brightly on one side and dimly on the other. blockSize must be odd, and C is subtracted from the local mean to bias the result.


Task 8: Find and measure contours

Contours turn a binary mask into a list of objects you can count and measure.

contours, _ = cv2.findContours(otsu, cv2.RETR_EXTERNAL,
                               cv2.CHAIN_APPROX_SIMPLE)

results = []
for c in contours:
    area = cv2.contourArea(c)
    if area < 200:                    # drop noise specks
        continue
    x, y, w, h = cv2.boundingRect(c)
    perim = cv2.arcLength(c, True)
    circularity = 4 * np.pi * area / (perim ** 2) if perim else 0
    results.append({"area": area, "box": (x, y, w, h),
                    "aspect": w / h, "circularity": round(circularity, 2)})

print(f"found {len(results)} objects")

circularity A shape score computed as 4 pi times area divided by perimeter squared. It equals 1.0 for a perfect circle and drops toward 0 for long thin shapes. is the single most useful shape number. Work it out for a circle of radius rr:

area=πr2,perimeter=2πr\text{area} = \pi r^2, \qquad \text{perimeter} = 2\pi r circularity=4π(πr2)(2πr)2=4π2r24π2r2=1.0\text{circularity} = \frac{4\pi (\pi r^2)}{(2\pi r)^2} = \frac{4\pi^2 r^2}{4\pi^2 r^2} = 1.0

And for a square of side aa:

circularity=4πa2(4a)2=4πa216a2=π4=0.785\text{circularity} = \frac{4\pi a^2}{(4a)^2} = \frac{4\pi a^2}{16a^2} = \frac{\pi}{4} = 0.785

# Shape Circularity target Typical aspect ratio
1 Circle 1 1
2 Square 0.79 1
3 Equilateral triangle 0.6 ~1.15
4 3:1 rectangle 0.59 3
5 Ragged noise blob 0.2 to 0.4 varies

Circularity values for common shapes — a cheap and reliable classifier

Two contour arguments are worth knowing. RETR_EXTERNAL returns only outer contours, ignoring holes; use RETR_TREE when you need the nesting, such as counting holes in a part. CHAIN_APPROX_SIMPLE stores only the corner points of straight segments instead of every pixel, which cuts memory a lot with no loss for measurement.


Task 9: Read video and webcam

cap = cv2.VideoCapture(0)             # 0 = default camera; or a filename

if not cap.isOpened():
    raise RuntimeError("could not open the video source")

fps = cap.get(cv2.CAP_PROP_FPS)
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"{w}x{h} at {fps:.1f} fps")

writer = cv2.VideoWriter("out.mp4", cv2.VideoWriter_fourcc(*"mp4v"),
                         fps if fps > 0 else 30.0, (w, h))

try:
    while True:
        ok, frame = cap.read()
        if not ok:
            break                     # end of file, or camera unplugged
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        edges = cv2.Canny(gray, 100, 200)
        out = cv2.cvtColor(edges, cv2.COLOR_GRAY2BGR)
        writer.write(out)
        cv2.imshow("edges", out)
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break
finally:
    cap.release()
    writer.release()
    cv2.destroyAllWindows()

Three things here save a lot of pain.

writer size must exactly match the frames you write. If you resize a frame and forget to update the writer size, VideoWriter writes nothing and reports no error. You get a zero-byte file.

The finally block. Without cap.release(), the camera stays locked and the next run fails to open it. On some systems only a reboot clears it.

waitKey(1) inside the loop. It is not just for quitting; imshow will not actually paint anything without a waitKey call.


Task 10: Batch process a folder

Real work is never one image. Here is a template that processes a directory and writes a report.

import cv2, csv
from pathlib import Path

IN_DIR, OUT_DIR = Path("input"), Path("output")
OUT_DIR.mkdir(exist_ok=True)
EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif"}

rows, skipped = [], []
for p in sorted(IN_DIR.iterdir()):
    if p.suffix.lower() not in EXTS:
        continue
    img = cv2.imread(str(p))
    if img is None:
        skipped.append(p.name)
        continue

    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    thr, mask = cv2.threshold(gray, 0, 255,
                              cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
                               cv2.CHAIN_APPROX_SIMPLE)
    big = [c for c in cnts if cv2.contourArea(c) > 200]

    cv2.imwrite(str(OUT_DIR / f"{p.stem}_mask.png"), mask)
    rows.append([p.name, img.shape[1], img.shape[0],
                 round(float(gray.mean()), 1), int(thr), len(big)])

with open("report.csv", "w", newline="") as f:
    wr = csv.writer(f)
    wr.writerow(["file", "width", "height", "mean_gray", "otsu_thr", "objects"])
    wr.writerows(rows)

print(f"processed {len(rows)} images, skipped {len(skipped)}")

That CSV is more valuable than it looks. Sort by mean_gray and the darkest and brightest files appear at the ends, which is where your algorithm will fail. Sort by otsu_thr and any wild swing tells you the lighting is unstable. Sort by objects and the images with zero or a suspiciously high count are the ones to inspect first.

This is error analysis, and it costs you one extra csv.writer. It is the habit that separates projects that improve from projects that plateau, and it is the core of the CV project workflow.


Practice project

Combine the ten tasks into one small tool. It should take a folder of photos of coins on a plain background and report how many coins are in each photo.

Then answer these from your CSV:

  1. Which image gave the highest count, and is that count correct?
  2. Which gave zero, and why?
  3. What is the smallest area of a genuine coin? Set your area filter just below it.
  4. If you drop the circularity filter, how many extra false objects appear?

Question 3 is the important one. Picking the area threshold from measured data rather than guessing is the difference between a rule that holds and a rule you keep re-tuning.

Summary

You now have a working OpenCV install and the ten operations that make up most vision code: load and inspect, resize with the right interpolation, crop safely, convert colour spaces, draw overlays, blur, threshold both fixed and automatically, extract and measure contours, handle video, and batch process a folder into a report.

Two habits matter more than any individual function. Always check imread for None, because OpenCV fails silently. And always write per-image measurements to a file, because the summary number hides exactly the cases you need to see.

What comes next

How to evaluate vision models is next, and it answers the question your coin counter raised: how do you actually know whether it is any good? After that, edge detection and thresholding goes deeper into getting clean binary output from difficult images, and morphology and contours covers cleaning masks and measuring shapes properly.

Test your understanding
You resize a segmentation mask (where pixel values are class IDs 0 to 9) from 512x512 down to 256x256 using cv2.INTER_LINEAR. What goes wrong?
Test your understanding
Your batch script writes an out.mp4 that is 0 bytes, with no error message. The frames display correctly in an imshow window. What is the most likely cause?

Frequently asked questions

Which OpenCV package should I install?
Start with opencv-python. Switch to opencv-contrib-python if you need SIFT, SURF, or the tracking modules. Use the headless variants on servers and in Docker images where there is no display. Install exactly one of the four, because they all provide the cv2 module and having two installed produces unpredictable behaviour.
Why does cv2.imread return None instead of raising an error?
OpenCV's C++ core returns an empty matrix on failure, and the Python binding maps that to None. It is a design choice, not a bug. The practical consequence is that a typo in a path produces a confusing NoneType error several lines later, so an explicit check with a clear message right after every imread is worth the two lines.
Should I use OpenCV or PIL for image loading?
OpenCV if you will do further processing, because everything downstream expects NumPy arrays and OpenCV's operations are heavily optimized. PIL is fine for simple loading, format conversion, and saving, and it handles some formats and metadata more gracefully. Mixing them is fine as long as you remember OpenCV is BGR and PIL is RGB.
How do I show OpenCV images in a Jupyter notebook?
cv2.imshow does not work reliably in notebooks. Convert to RGB and use matplotlib instead: plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)). Skipping the conversion is why notebook images so often come out looking blue.
How do I know what blur amount to use?
Sweep sigma from 0.5 to 3.0 and measure the variance of the Laplacian at each level, then look at where your downstream step (threshold, edge detection, contours) starts producing clean output. Pick the smallest sigma that gets you there. Anything larger is detail thrown away for no gain.
Start typing to search across all content
navigate Enter open Esc close