# Real-Time Object Detection in C++ with YOLOv8 and OpenCV DNN

*A complete, code-first walkthrough of building a live object detector using YOLOv8, ONNX, and OpenCV's DNN module — no Python required at runtime.*

## Introduction

Object detection has become one of the most practical entry points into computer vision — it's the backbone of security cameras, autonomous vehicles, retail analytics, robotics, and countless embedded systems. While Python dominates the prototyping world (thanks to PyTorch and the `ultralytics` library), production systems — especially on embedded devices, robotics platforms, or performance-critical applications — often need C++.

This post walks through building a **real-time object detector in C++** using **YOLOv8** exported to **ONNX** and run through **OpenCV's DNN module**. By the end, you'll have a working webcam application that draws bounding boxes and class labels around detected objects in real time, along with a solid understanding of *why* each step exists.

We'll cover:

1.  Why YOLOv8 + OpenCV DNN, and how it compares to alternatives
    
2.  Exporting the model
    
3.  Setting up the C++ project (CMake)
    
4.  Loading the model and preprocessing frames
    
5.  Understanding and decoding YOLOv8's raw output tensor
    
6.  Non-Max Suppression and drawing results
    
7.  The full, buildable `main.cpp`
    
8.  Performance tuning and next steps
    

* * *

## 1\. Why YOLOv8 + OpenCV DNN?

There's no shortage of object detection architectures — YOLO, SSD, Faster R-CNN, DETR — and each makes different speed/accuracy tradeoffs.

| Model | Speed | Accuracy | OpenCV DNN Support | Verdict |
| --- | --- | --- | --- | --- |
| **YOLOv8 (ONNX)** | Fast | Good | Excellent, well-tested | ✅ Best fit for real-time C++ |
| MobileNet-SSD | Very fast | Lower | Excellent, mature | Good for constrained/embedded devices |
| Faster R-CNN | Slow (two-stage) | High | Supported but clunky | Not ideal for real-time |
| DETR | Slow (transformer) | High | Unreliable — attention ops often unsupported | Avoid in `cv::dnn` |

YOLOv8 wins here because it's a **single-stage detector** (one forward pass, no region-proposal step), exports cleanly to ONNX, and OpenCV's DNN backend has mature support for the operations it uses (convolutions, concatenation, resizing). This means you get real-time inference on a CPU alone — no CUDA, no TensorRT, no Python runtime dependency.

* * *

## 2\. Exporting YOLOv8 to ONNX

This is the *only* step that touches Python, and it's a one-time operation — the exported `.onnx` file is all your C++ application needs afterward.

```bash
pip install ultralytics
yolo export model=yolov8n.pt format=onnx opset=12
```

A few notes on this command:

*   `yolov8n.pt` is the **nano** variant — smallest and fastest, ideal for CPU inference. Swap in `yolov8s.pt` or `yolov8m.pt` for better accuracy at the cost of speed.
    
*   `opset=12` ensures broad compatibility with OpenCV's ONNX importer. Higher opsets can introduce ops OpenCV doesn't support yet.
    
*   The output, `yolov8n.onnx`, is a self-contained model file — copy it into your C++ project's resources folder.
    

You'll also want the COCO class names (80 categories) in a simple text file, `coco.names`, one class per line (`person`, `bicycle`, `car`, ...). These are widely available or can be generated from the `ultralytics` package.

* * *

## 3\. Project Setup with CMake

Here's a clean project structure:

```plaintext
yolo-detector/
├── CMakeLists.txt
├── main.cpp
├── models/
│   └── yolov8n.onnx
└── coco.names
```

**CMakeLists.txt:**

```cmake
cmake_minimum_required(VERSION 3.10)
project(YoloDetector)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(OpenCV REQUIRED)
include_directories(${OpenCV_INCLUDE_DIRS})

add_executable(yolo_detector main.cpp)
target_link_libraries(yolo_detector ${OpenCV_LIBS})
```

Build it the usual way:

```bash
mkdir build && cd build
cmake ..
make
./yolo_detector
```

Make sure OpenCV was built with the DNN module (it is by default in most package managers and prebuilt binaries from 4.x onward).

* * *

## 4\. Loading the Model and Preprocessing Frames

OpenCV's `cv::dnn::readNetFromONNX` handles model loading directly — no conversion layer needed.

```cpp
cv::dnn::Net net = cv::dnn::readNetFromONNX("models/yolov8n.onnx");
net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV);
net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU);
```

If you've compiled OpenCV with CUDA support, swap the target:

```cpp
net.setPreferableBackend(cv::dnn::DNN_BACKEND_CUDA);
net.setPreferableTarget(cv::dnn::DNN_TARGET_CUDA);
```

**Preprocessing** matters a lot here. YOLOv8 expects a 640×640 RGB image, normalized to `[0, 1]`, in `NCHW` format. OpenCV's `blobFromImage` does all of this in one call:

```cpp
cv::Mat blob;
cv::dnn::blobFromImage(
    frame, blob,
    1.0 / 255.0,             // scale factor: normalize to [0,1]
    cv::Size(640, 640),      // target size YOLOv8 expects
    cv::Scalar(),            // no mean subtraction
    true,                    // swapRB: BGR -> RGB
    false                    // no cropping
);
net.setInput(blob);
```

One subtlety: **YOLOv8 doesn't do simple resizing internally** — it typically expects "letterboxing" (resize while preserving aspect ratio, then pad) for best accuracy. For simplicity, this walkthrough uses direct resizing and rescales boxes back proportionally, which works well for most use cases; letterboxing is covered as a stretch improvement at the end.

* * *

## 5\. Understanding YOLOv8's Output Tensor

This is the part that trips people up most, because YOLOv8's output format **differs from YOLOv5**, and a lot of outdated tutorials still use the old decoding logic.

Running inference:

```cpp
std::vector<cv::Mat> outputs;
net.forward(outputs, net.getUnconnectedOutLayersNames());
```

For the standard 80-class COCO model, `outputs[0]` has shape:

```plaintext
[1, 84, 8400]
```

Breaking this down:

*   **8400** = total number of candidate detections (anchor points across three feature map scales: 80×80 + 40×40 + 20×20 = 8400)
    
*   **84** = 4 box coordinates (`cx, cy, w, h`) + 80 class confidence scores
    

Note there's **no separate "objectness" score** like in YOLOv5 — YOLOv8 folds that into the class scores directly. This is the detail most outdated code gets wrong.

The tensor comes out as `[84, 8400]` (after dropping the batch dimension), which is the *transpose* of what's convenient to iterate over. We reshape and transpose it first:

```cpp
cv::Mat output = outputs[0];                     // shape: [1, 84, 8400]
output = output.reshape(1, 84);                   // shape: [84, 8400]
cv::transpose(output, output);                    // shape: [8400, 84]
```

Now each row `i` represents one candidate detection: `[cx, cy, w, h, class0_score, class1_score, ..., class79_score]`.

* * *

## 6\. Postprocessing: Filtering, Scaling, and NMS

For each of the 8400 rows, we extract the highest class score, filter by a confidence threshold, and convert the box format.

```cpp
std::vector<int> class_ids;
std::vector<float> confidences;
std::vector<cv::Rect> boxes;

float x_factor = frame.cols / 640.0f;
float y_factor = frame.rows / 640.0f;

float* data = (float*)output.data;
const int rows = output.rows; // 8400
const int dimensions = output.cols; // 84

for (int i = 0; i < rows; ++i) {
    float* row = data + i * dimensions;
    float* classes_scores = row + 4;

    cv::Mat scores(1, dimensions - 4, CV_32FC1, classes_scores);
    cv::Point class_id;
    double max_class_score;
    cv::minMaxLoc(scores, 0, &max_class_score, 0, &class_id);

    if (max_class_score > 0.25) { // confidence threshold
        float cx = row[0];
        float cy = row[1];
        float w  = row[2];
        float h  = row[3];

        int left   = int((cx - 0.5 * w) * x_factor);
        int top    = int((cy - 0.5 * h) * y_factor);
        int width  = int(w * x_factor);
        int height = int(h * y_factor);

        boxes.push_back(cv::Rect(left, top, width, height));
        confidences.push_back((float)max_class_score);
        class_ids.push_back(class_id.x);
    }
}
```

Because YOLO predicts multiple overlapping boxes for the same object, we run **Non-Max Suppression** to collapse duplicates:

```cpp
std::vector<int> nms_result;
cv::dnn::NMSBoxes(boxes, confidences, 0.25, 0.45, nms_result);
// 0.25 = confidence threshold, 0.45 = IoU threshold
```

`nms_result` now holds the indices of the boxes that survive suppression — these are your final detections.

* * *

## 7\. Drawing Results and the Real-Time Loop

Load class names once at startup:

```cpp
std::vector<std::string> class_list;
std::ifstream ifs("coco.names");
std::string line;
while (std::getline(ifs, line)) class_list.push_back(line);
```

Draw each surviving detection:

```cpp
for (int idx : nms_result) {
    cv::Rect box = boxes[idx];
    cv::rectangle(frame, box, cv::Scalar(0, 255, 0), 2);

    std::string label = class_list[class_ids[idx]] + " " +
                         cv::format("%.2f", confidences[idx]);

    int baseline;
    cv::Size label_size = cv::getTextSize(label, cv::FONT_HERSHEY_SIMPLEX, 0.5, 1, &baseline);
    cv::rectangle(frame, cv::Point(box.x, box.y - label_size.height - 8),
                  cv::Point(box.x + label_size.width, box.y), cv::Scalar(0, 255, 0), cv::FILLED);
    cv::putText(frame, label, cv::Point(box.x, box.y - 4),
                cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 0, 0), 1);
}
```

And the main webcam loop, with FPS tracking:

```cpp
cv::VideoCapture cap(0);
if (!cap.isOpened()) {
    std::cerr << "Error: could not open webcam\n";
    return -1;
}

cv::Mat frame;
while (cap.read(frame)) {
    auto start = std::chrono::high_resolution_clock::now();

    // ... preprocessing, inference, postprocessing, drawing (as above) ...

    auto end = std::chrono::high_resolution_clock::now();
    double fps = 1000.0 / std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    cv::putText(frame, cv::format("FPS: %.1f", fps), cv::Point(10, 30),
                cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 255, 255), 2);

    cv::imshow("YOLOv8 Detection", frame);
    if (cv::waitKey(1) == 'q') break;
}
```

* * *

## 8\. Full `main.cpp`

Putting everything together into a single, buildable file:

```cpp
#include <opencv2/opencv.hpp>
#include <opencv2/dnn.hpp>
#include <fstream>
#include <iostream>
#include <chrono>

std::vector<std::string> loadClassList(const std::string& path) {
    std::vector<std::string> class_list;
    std::ifstream ifs(path);
    std::string line;
    while (std::getline(ifs, line)) class_list.push_back(line);
    return class_list;
}

int main() {
    // --- Load model ---
    cv::dnn::Net net = cv::dnn::readNetFromONNX("models/yolov8n.onnx");
    net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV);
    net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU);

    std::vector<std::string> class_list = loadClassList("coco.names");

    // --- Open webcam ---
    cv::VideoCapture cap(0);
    if (!cap.isOpened()) {
        std::cerr << "Error: could not open webcam\n";
        return -1;
    }

    cv::Mat frame;
    const float CONF_THRESHOLD = 0.25f;
    const float NMS_THRESHOLD  = 0.45f;
    const int   INPUT_SIZE     = 640;

    while (cap.read(frame)) {
        auto t0 = std::chrono::high_resolution_clock::now();

        // --- Preprocess ---
        cv::Mat blob;
        cv::dnn::blobFromImage(frame, blob, 1.0 / 255.0,
                                cv::Size(INPUT_SIZE, INPUT_SIZE),
                                cv::Scalar(), true, false);
        net.setInput(blob);

        // --- Inference ---
        std::vector<cv::Mat> outputs;
        net.forward(outputs, net.getUnconnectedOutLayersNames());

        // --- Reshape output: [1,84,8400] -> [8400,84] ---
        cv::Mat output = outputs[0].reshape(1, 84);
        cv::transpose(output, output);

        float x_factor = frame.cols / (float)INPUT_SIZE;
        float y_factor = frame.rows / (float)INPUT_SIZE;

        std::vector<int> class_ids;
        std::vector<float> confidences;
        std::vector<cv::Rect> boxes;

        float* data = (float*)output.data;
        const int rows = output.rows;
        const int dimensions = output.cols;

        for (int i = 0; i < rows; ++i) {
            float* row = data + i * dimensions;
            float* classes_scores = row + 4;

            cv::Mat scores(1, dimensions - 4, CV_32FC1, classes_scores);
            cv::Point class_id;
            double max_class_score;
            cv::minMaxLoc(scores, 0, &max_class_score, 0, &class_id);

            if (max_class_score > CONF_THRESHOLD) {
                float cx = row[0], cy = row[1], w = row[2], h = row[3];

                int left   = int((cx - 0.5f * w) * x_factor);
                int top    = int((cy - 0.5f * h) * y_factor);
                int width  = int(w * x_factor);
                int height = int(h * y_factor);

                boxes.emplace_back(left, top, width, height);
                confidences.push_back((float)max_class_score);
                class_ids.push_back(class_id.x);
            }
        }

        // --- NMS ---
        std::vector<int> nms_result;
        cv::dnn::NMSBoxes(boxes, confidences, CONF_THRESHOLD, NMS_THRESHOLD, nms_result);

        // --- Draw ---
        for (int idx : nms_result) {
            cv::Rect box = boxes[idx];
            cv::rectangle(frame, box, cv::Scalar(0, 255, 0), 2);

            std::string label = class_list[class_ids[idx]] + " " +
                                 cv::format("%.2f", confidences[idx]);

            int baseline;
            cv::Size label_size = cv::getTextSize(label, cv::FONT_HERSHEY_SIMPLEX, 0.5, 1, &baseline);
            cv::rectangle(frame,
                          cv::Point(box.x, box.y - label_size.height - 8),
                          cv::Point(box.x + label_size.width, box.y),
                          cv::Scalar(0, 255, 0), cv::FILLED);
            cv::putText(frame, label, cv::Point(box.x, box.y - 4),
                        cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 0, 0), 1);
        }

        // --- FPS overlay ---
        auto t1 = std::chrono::high_resolution_clock::now();
        double ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
        double fps = ms > 0 ? 1000.0 / ms : 0.0;
        cv::putText(frame, cv::format("FPS: %.1f", fps), cv::Point(10, 30),
                    cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 255, 255), 2);

        cv::imshow("YOLOv8 Detection", frame);
        if (cv::waitKey(1) == 'q') break;
    }

    cap.release();
    cv::destroyAllWindows();
    return 0;
}
```

This compiles and runs out of the box (given the CMake setup from Section 3) and gives you a live, labeled bounding-box overlay on your webcam feed with an FPS counter.

* * *

## 9\. Performance Tuning

A few practical levers once the base app is running:

*   **Model size vs. speed**: `yolov8n` (nano) is fastest but least accurate; `yolov8s`/`yolov8m` trade speed for accuracy. Re-export and swap the `.onnx` file to compare.
    
*   **Input resolution**: Dropping to `416×416` or `320×320` input size increases FPS substantially at some accuracy cost — useful on constrained hardware.
    
*   **Threading**: `cv::setNumThreads(N)` can help OpenCV's DNN backend use more CPU cores.
    
*   **Confidence/NMS thresholds**: Raising `CONF_THRESHOLD` reduces false positives and slightly speeds up postprocessing (fewer boxes go into NMS).
    
*   **GPU acceleration**: If OpenCV was built with CUDA support, switching the backend/target (shown in Section 4) can give a large speedup.
    

* * *

## 10\. Next Steps and Extensions

Once the base pipeline works, there's a lot of room to build on it:

*   **Letterboxing preprocessing** — pad instead of stretch when resizing to 640×640, which improves accuracy on non-square frames (this is what `ultralytics` does internally in Python).
    
*   **Object tracking** — assign persistent IDs across frames using `cv::TrackerCSRT` or a simple centroid-distance tracker, turning detection into tracking.
    
*   **Custom-trained models** — fine-tune YOLOv8 on your own dataset (e.g., a specific product, animal, or defect type) and swap in the resulting ONNX file; the C++ code doesn't change.
    
*   **TensorRT export** — for NVIDIA hardware, exporting to TensorRT instead of plain ONNX can significantly boost inference speed.
    
*   **Video file / RTSP input** — swap `cv::VideoCapture(0)` for a file path or RTSP stream URL to run this on recorded footage or IP cameras.
