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

Search for a command to run...

No comments yet. Be the first to comment.
Recently it was announced that Sandpack will no longer be actively maintained. Not really great news to wake up to, but C'est la vie. I personally was introduced to Sandpack when going through the blo

ERPNext provides a powerful REST API that allows you to integrate external applications, automate workflows, and extend functionality. This guide will walk you through both consuming existing APIs and creating your own custom APIs in ERPNext. Underst...

C++ gives developers a lot of control over memory and performance. That control is also the reason many bugs in C++ are subtle, hard to detect, and sometimes disastrous.Most of these issues are not caused by advanced features, but by small, everyday ...

When a chess engine looks at a position, it does not rely on intuition or experience. Instead, it assumes two things: It will always try to play the best possible move Its opponent will also always try to play the best possible move The Minimax a...

A complete, code-first walkthrough of building a live object detector using YOLOv8, ONNX, and OpenCV's DNN module — no Python required at runtime.
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:
Why YOLOv8 + OpenCV DNN, and how it compares to alternatives
Exporting the model
Setting up the C++ project (CMake)
Loading the model and preprocessing frames
Understanding and decoding YOLOv8's raw output tensor
Non-Max Suppression and drawing results
The full, buildable main.cpp
Performance tuning and next steps
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.
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.
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.
Here's a clean project structure:
yolo-detector/
├── CMakeLists.txt
├── main.cpp
├── models/
│ └── yolov8n.onnx
└── coco.names
CMakeLists.txt:
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:
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).
OpenCV's cv::dnn::readNetFromONNX handles model loading directly — no conversion layer needed.
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:
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:
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.
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:
std::vector<cv::Mat> outputs;
net.forward(outputs, net.getUnconnectedOutLayersNames());
For the standard 80-class COCO model, outputs[0] has shape:
[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:
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].
For each of the 8400 rows, we extract the highest class score, filter by a confidence threshold, and convert the box format.
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:
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.
Load class names once at startup:
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:
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:
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;
}
main.cppPutting everything together into a single, buildable file:
#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.
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.
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.