---
title: "Detect colors and track objects with OpenCV HSV"
description: "Track a coloured object in a webcam feed with OpenCV. Learn why HSV beats BGR, how to build and clean a mask, find the object, and handle red's wrap-around."
url: https://articles.sythra.ai/articles/opencv-color-detection-hsv-tracking
slug: opencv-color-detection-hsv-tracking
author: "vaibhavkothari"
author_url: https://articles.sythra.ai/writers/vaibhavkothari
date_published: 2026-11-12T21:11:00.153Z
date_modified: 2026-11-12T21:11:00.153Z
topics: ["Python", "Opencv", "Computer Vision"]
reading_time_minutes: 7
publisher: "Sythra"
publisher_url: https://sythra.ai
access: free
language: en
---

# Detect colors and track objects with OpenCV HSV

> Track a coloured object in a webcam feed with OpenCV. Learn why HSV beats BGR, how to build and clean a mask, find the object, and handle red's wrap-around.

Source: https://articles.sythra.ai/articles/opencv-color-detection-hsv-tracking · Author: vaibhavkothari · Published: 2026-11-12 · Reading time: 7 min · Topics: Python, Opencv, Computer Vision

Before deep learning, the fastest way to follow an object was to follow its colour. It is still the right tool when the object is distinctive — a red ball, a green marker cap, a bright sticker — and you want something that runs at full frame rate on a laptop with no model files. This guide builds a complete colour tracker and explains the one trick that makes it work: switching from BGR to HSV.

## Install

```bash
python -m pip install opencv-python numpy
```

If `import cv2` fails afterwards, see [No module named cv2](https://articles.sythra.ai/articles/no-module-named-cv2-fix).

## Why not just compare BGR values?

OpenCV loads images as **BGR** (blue, green, red), each 0–255. A "red" object does not have one BGR value. In bright light it might be `(40, 40, 230)`, in shadow `(10, 10, 110)`. The channels all move together when lighting changes, so a rule like "red above 200" fails whenever a cloud passes.

HSV separates the parts:

| Channel | Meaning | OpenCV range |
|---|---|---|
| **H**ue | The colour itself, as an angle on a colour wheel | 0–179 |
| **S**aturation | Vivid (high) vs washed out/grey (low) | 0–255 |
| **V**alue | Bright vs dark | 0–255 |

Note the quirk: OpenCV stores hue as 0–179, not 0–359, because it halves the angle to fit in one byte. Red is near 0 (and 179), green near 60, blue near 120.

Now "red" becomes "hue near 0, reasonably saturated, not too dark", a rule that survives lighting changes far better.

```python
import cv2
import numpy as np

print(cv2.cvtColor(np.uint8([[[0, 0, 255]]]), cv2.COLOR_BGR2HSV))
# [[[  0 255 255]]]   pure red -> hue 0
print(cv2.cvtColor(np.uint8([[[255, 0, 0]]]), cv2.COLOR_BGR2HSV))
# [[[120 255 255]]]   pure blue -> hue 120
```

## Step 1 — Make a mask

A mask is a black-and-white image: white where a pixel falls inside your colour range, black elsewhere.

```python
import cv2
import numpy as np

frame = cv2.imread("ball.jpg")
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)

lower = np.array([100, 120, 70])    # blue-ish: H, S, V minimums
upper = np.array([130, 255, 255])   # H, S, V maximums

mask = cv2.inRange(hsv, lower, upper)
cv2.imshow("mask", mask)
cv2.waitKey(0)
```

Set the **saturation and value minimums** above zero. Without them, grey walls and dark shadows (which have a hue value but no real colour) leak into the mask.

## Step 2 — Red wraps around

Hue is circular, and red sits at *both* ends: 0–10 and 170–179. One range cannot cover it, so use two and combine them:

```python
lower1, upper1 = np.array([0, 120, 70]),   np.array([10, 255, 255])
lower2, upper2 = np.array([170, 120, 70]), np.array([179, 255, 255])

mask = cv2.inRange(hsv, lower1, upper1) | cv2.inRange(hsv, lower2, upper2)
```

Forgetting this is the classic reason a red tracker "works but only on orange-ish red".

## Step 3 — Clean the mask

Raw masks are speckled with stray white pixels and have holes inside the object. **Morphological** operations fix both:

```python
kernel = np.ones((5, 5), np.uint8)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)    # removes small white specks
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)   # fills small holes
```

Optionally blur the frame (`cv2.GaussianBlur(frame, (7, 7), 0)`) before converting to HSV to reduce sensor noise.

## Step 4 — Find the object

Contours turn the mask into objects you can measure:

```python
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

if contours:
    biggest = max(contours, key=cv2.contourArea)
    if cv2.contourArea(biggest) > 500:          # ignore tiny blobs
        x, y, w, h = cv2.boundingRect(biggest)
        cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)

        m = cv2.moments(biggest)
        cx = int(m["m10"] / m["m00"])
        cy = int(m["m01"] / m["m00"])
        cv2.circle(frame, (cx, cy), 6, (0, 255, 255), -1)
```

`cv2.moments` gives the centre of the shape. The area threshold (500 pixels here) is what separates "the ball" from noise; tune it to your camera distance.

## Step 5 — Run it live with a trail

```python
import cv2
import numpy as np
from collections import deque

LOWER1, UPPER1 = np.array([0, 120, 70]),   np.array([10, 255, 255])
LOWER2, UPPER2 = np.array([170, 120, 70]), np.array([179, 255, 255])
KERNEL = np.ones((5, 5), np.uint8)

trail = deque(maxlen=32)
cap = cv2.VideoCapture(0)

while True:
    ok, frame = cap.read()
    if not ok:
        break

    frame = cv2.flip(frame, 1)                       # mirror view
    blurred = cv2.GaussianBlur(frame, (7, 7), 0)
    hsv = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV)

    mask = cv2.inRange(hsv, LOWER1, UPPER1) | cv2.inRange(hsv, LOWER2, UPPER2)
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, KERNEL)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, KERNEL)

    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    center = None
    if contours:
        biggest = max(contours, key=cv2.contourArea)
        if cv2.contourArea(biggest) > 500:
            m = cv2.moments(biggest)
            center = (int(m["m10"] / m["m00"]), int(m["m01"] / m["m00"]))
            (x, y), radius = cv2.minEnclosingCircle(biggest)
            cv2.circle(frame, (int(x), int(y)), int(radius), (0, 255, 0), 2)

    trail.appendleft(center)
    for i in range(1, len(trail)):
        if trail[i - 1] is None or trail[i] is None:
            continue
        thickness = max(1, int(6 * (1 - i / len(trail))))
        cv2.line(frame, trail[i - 1], trail[i], (0, 165, 255), thickness)

    cv2.imshow("tracker", frame)
    cv2.imshow("mask", mask)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break

cap.release()
cv2.destroyAllWindows()
```

Keep the `mask` window open while you tune. When tracking misbehaves, the mask shows why in a second.

## Find the right range for your object

Guessing numbers is slow. Add sliders:

```python
def nothing(_):
    pass

cv2.namedWindow("tune")
for name, start, top in [("H min", 0, 179), ("H max", 179, 179),
                         ("S min", 0, 255), ("S max", 255, 255),
                         ("V min", 0, 255), ("V max", 255, 255)]:
    cv2.createTrackbar(name, "tune", start, top, nothing)

# inside your loop:
lower = np.array([cv2.getTrackbarPos(n, "tune") for n in ("H min", "S min", "V min")])
upper = np.array([cv2.getTrackbarPos(n, "tune") for n in ("H max", "S max", "V max")])
mask = cv2.inRange(hsv, lower, upper)
```

Drag the sliders until only your object is white, then copy the six numbers into your constants.

## When colour tracking fails

| Symptom | Cause | Fix |
|---|---|---|
| Mask picks up skin or wood | Similar hue in the background | Raise saturation minimum, use a more unusual colour |
| Object disappears in dim light | Value floor too high | Lower V minimum, or add light |
| Mask flickers | Auto-exposure or auto-white-balance | Fix exposure if the camera allows it |
| Tracks the wrong blob | Several objects of that colour | Track the largest, or filter by shape |

Colour is brittle compared with learned detectors: it knows nothing about what an object *is*. If you need to find faces, hands or people regardless of colour, use a model — for example [MediaPipe face detection](https://articles.sythra.ai/articles/face-detection-python-mediapipe-vs-haar) or [hand tracking](https://articles.sythra.ai/articles/count-fingers-mediapipe-python).

## FAQ

### Why use HSV instead of RGB for colour detection?

HSV separates colour (hue) from brightness (value), so a colour range stays valid when lighting changes. In RGB all three channels change together with brightness, making simple thresholds unreliable.

### Why is the hue range 0 to 179 in OpenCV?

OpenCV halves the usual 0–359 degree hue so it fits in an 8-bit value. Red is near 0 and 179, green near 60 and blue near 120.

### How do I detect red in OpenCV?

Red wraps around the hue circle, so create two masks, one for hues 0–10 and one for 170–179, and combine them with a bitwise OR.

### How do I get the position of the detected object?

Find contours on the cleaned mask, pick the largest, and use `cv2.moments` to compute its centre, or `cv2.boundingRect` for a box.

## Next reading on Sythra Articles

- [Draw in the air with MediaPipe finger tracking](https://articles.sythra.ai/articles/draw-in-air-mediapipe-opencv)
- [Face detection: MediaPipe vs Haar cascades](https://articles.sythra.ai/articles/face-detection-python-mediapipe-vs-haar)
- [Extract text from an image with Python OCR](https://articles.sythra.ai/articles/extract-text-from-image-python-ocr)

## Glossary (terms defined in this article)
- **HSV** (medium) — A colour model with three channels: Hue (which colour), Saturation (how vivid), Value (how bright)
- **Contours** (medium) — Outlines of connected white regions in a binary image
