OpenCV Setup + First 10 Tasks in Python
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- 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.
graph LR A["Read<br/>imread / VideoCapture"] --> B["Check for None"] B --> C["Convert<br/>colour space, dtype"] C --> D["Process<br/>blur, threshold, detect"] D --> E["Measure<br/>counts, areas, positions"] E --> F["Write or display<br/>imwrite / imshow"]
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:
Apply it to both dimensions:
To get a square 640×640 for a model, pad the short side. Total padding needed is , split evenly:
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_NEAREST | INTER_LINEAR | INTER_AREA | INTER_CUBIC | |
|---|---|---|---|---|
| How it works | Copy nearest pixel | Blend 2x2 neighbours | Average over the source region | Fit a curve over 4x4 |
| Best for | Label masks, segmentation maps | General enlarging | Shrinking | Enlarging when quality matters |
| Speed | Fastest | Fast | Fast | Slow |
| Creates new values? | No | Yes | Yes | Yes |
| Aliasing when shrinking | Severe | Noticeable | None | Noticeable |
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:
For a pure green pixel :
For a pure blue pixel :
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.
- Fixed value: lighting is tightly controlled and never changes
- Otsu: the histogram has two clear peaks and lighting is even across the frame
- Adaptive: brightness varies across the image, such as documents or outdoor scenes
- The object and background overlap in brightness — you need colour or texture instead
- There are more than two meaningful groups — thresholding only ever gives you two
- The histogram is one smooth hill with no valley — Otsu will pick something, but it will be arbitrary
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 :
And for a square of side :
| # | 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.
graph TD A["Read image"] --> B["Resize long side to 800"] B --> C["Grayscale"] C --> D["Gaussian blur, sigma 2"] D --> E["Otsu threshold"] E --> F["findContours, RETR_EXTERNAL"] F --> G["Filter: area over 500<br/>and circularity over 0.7"] G --> H["Draw boxes and count"] H --> I["Append row to CSV"] I --> J["Sort CSV by count,<br/>inspect the extremes"]
Then answer these from your CSV:
- Which image gave the highest count, and is that count correct?
- Which gave zero, and why?
- What is the smallest area of a genuine coin? Set your area filter just below it.
- 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.
- Install exactly one of the four opencv pip packages inside a virtual environment.
- cv2.imread returns None on failure. Check for it every time.
- cv2.resize takes (width, height); img.shape gives (height, width). They are opposite orders.
- INTER_AREA when shrinking, INTER_LINEAR or INTER_CUBIC when enlarging, INTER_NEAREST for label masks.
- NumPy slices are views. Use .copy() when you plan to modify the crop.
- Grayscale is a weighted sum (0.299R + 0.587G + 0.114B), not a plain average.
- Otsu removes the magic threshold number; adaptive thresholding handles uneven lighting.
- Circularity is 1.0 for a circle and 0.785 for a square, which makes it a cheap shape filter.
- Always release VideoCapture and VideoWriter in a finally block.
- Batch jobs should write a per-image CSV, not just a summary count.
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.