1. 环境搭建与基础认知:OpenCV与YOLO的安装配置,理解检测流程

好,咱们直接开始。这一章说白了就是「把家伙事儿备齐,然后搞清楚这玩意儿到底怎么跑的」。我见过太多人一上来就调模型、改参数,结果环境没配好,折腾半天跑不起来——嗯,这种事我自己也干过。

1.1 为什么选OpenCV + YOLO?

你可能会问:检测框架那么多,为什么非要用OpenCV来跑YOLO?

我个人习惯是这么看的:YOLO负责「认出东西」,OpenCV负责「画出来」。两者配合,就像你有了一个眼神好的侦察兵,再加上一个手稳的绘图员。实际项目中,OpenCV的dnn模块可以直接加载YOLO的模型文件,不需要装PyTorch或TensorFlow那一整套。这对部署来说太友好了——你想想看,客户服务器上可能连GPU都没有,但OpenCV几乎哪都有。

我的经验: 有一次在树莓派上做项目,PyTorch死活装不上。换成OpenCV的dnn模块,十分钟搞定推理。所以别小看这个组合,轻量级部署的利器。

1.2 环境搭建:一步步来

咱们分两步走:先装OpenCV,再准备YOLO的模型文件。

1.2.1 安装OpenCV

Python环境下,最省事的办法就是pip。但我建议你创建一个虚拟环境,别把系统搞乱了。

# 创建虚拟环境(我习惯用venv)
python -m venv yolo_env
# 激活(Windows)
yolo_env\Scripts\activate
# 激活(Mac/Linux)
source yolo_env/bin/activate

# 安装OpenCV(包含contrib模块)
pip install opencv-python opencv-contrib-python

# 验证安装
python -c "import cv2; print(cv2.__version__)"

看到版本号输出来,就说明装好了。如果报错,多半是网络问题——换个国内镜像试试:

pip install opencv-python -i https://pypi.tuna.tsinghua.edu.cn/simple
注意: 别装成 opencv-python-headless,那个没有GUI功能,后面你连显示图片都做不到。我曾经踩过这个坑,调试了半天才发现。

1.2.2 准备YOLO模型文件

YOLO的模型文件有三样东西:权重文件(.weights)、配置文件(.cfg)、类别名称文件(.names)。你可以从官方GitHub下载,也可以用我下面这个脚本自动拉取:

import urllib.request
import os

# 创建模型目录
os.makedirs('yolo_files', exist_ok=True)

# 下载YOLOv4的权重和配置文件
urls = {
    'yolov4.weights': 'https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v3_optimal/yolov4.weights',
    'yolov4.cfg': 'https://raw.githubusercontent.com/AlexeyAB/darknet/master/cfg/yolov4.cfg',
    'coco.names': 'https://raw.githubusercontent.com/pjreddie/darknet/master/data/coco.names'
}

for name, url in urls.items():
    print(f'正在下载 {name}...')
    urllib.request.urlretrieve(url, f'yolo_files/{name}')

print('下载完成!')

这里我用的是YOLOv4,为什么?因为v4在速度和精度之间平衡得不错,而且OpenCV的dnn模块对它的支持最成熟。v5、v8那些虽然更新,但有时候反而兼容性会出问题。

1.3 理解检测流程:从输入到输出

好,环境搭好了。现在咱们聊聊YOLO到底是怎么工作的。说白了就三步:

  1. 图像预处理:把图片缩放到模型需要的尺寸(通常是416x416或608x608)
  2. 前向推理:把缩放后的图片扔进神经网络,得到一堆原始输出
  3. 后处理:从原始输出中解析出边界框、置信度、类别,再用NMS(非极大值抑制)去掉重复的框

你想想看,整个过程就像流水线——原料进去,成品出来。中间每个环节都可能出问题,所以咱们得把每一步都搞清楚。

1.3.1 图像预处理

OpenCV读进来的图片是BGR格式,而YOLO训练时用的是RGB。这个坑我一开始就踩过——检测结果全是错的,后来才发现颜色通道搞反了。

import cv2
import numpy as np

# 读取图片
img = cv2.imread('test.jpg')  # BGR格式

# 获取图片尺寸
height, width = img.shape[:2]

# 创建blob(OpenCV的预处理函数)
blob = cv2.dnn.blobFromImage(
    img, 
    scalefactor=1/255.0,  # 归一化到[0,1]
    size=(416, 416),      # 缩放到416x416
    mean=(0, 0, 0),       # 不减去均值
    swapRB=True,          # BGR -> RGB(就是这里!)
    crop=False
)

print(f'blob的形状: {blob.shape}')  # (1, 3, 416, 416)

blobFromImage这个函数,我建议你记住它的参数顺序。每次写代码时我都会默念一遍:图片、缩放因子、尺寸、均值、是否交换通道、是否裁剪。

1.3.2 前向推理

加载模型,然后把blob喂进去:

# 加载模型
net = cv2.dnn.readNet(
    'yolo_files/yolov4.weights', 
    'yolo_files/yolov4.cfg'
)

# 设置blob为输入
net.setInput(blob)

# 前向推理
# 获取所有输出层的名称
layer_names = net.getLayerNames()
output_layers = [layer_names[i - 1] for i in net.getUnconnectedOutLayers()]

# 推理
outputs = net.forward(output_layers)

print(f'输出层数量: {len(outputs)}')
print(f'每个输出的形状: {[o.shape for o in outputs]}')

这里有个细节:getUnconnectedOutLayers()返回的是输出层的索引,需要减1才能对应到layer_names。这个逻辑我第一次看的时候也懵了一下。

1.3.3 后处理:从原始输出到检测框

原始输出长什么样?每个输出层是一个三维数组,形状是 (batch, num_boxes, 85)。其中85的含义是:

索引范围 含义
0-3 边界框坐标 (cx, cy, w, h) —— 注意是相对于网格的偏移
4 目标置信度(有没有物体)
5-84 80个类别的置信度(COCO数据集)

后处理的核心就是解析这些数据,然后做NMS:

def post_process(outputs, img_width, img_height, conf_threshold=0.5, nms_threshold=0.4):
    boxes = []
    confidences = []
    class_ids = []
    
    for output in outputs:
        for detection in output:
            # 提取置信度
            scores = detection[5:]
            class_id = np.argmax(scores)
            confidence = scores[class_id]
            
            # 过滤低置信度
            if confidence > conf_threshold:
                # 将中心点坐标和宽高转换为像素坐标
                center_x = int(detection[0] * img_width)
                center_y = int(detection[1] * img_height)
                w = int(detection[2] * img_width)
                h = int(detection[3] * img_height)
                
                # 转换为左上角坐标
                x = int(center_x - w / 2)
                y = int(center_y - h / 2)
                
                boxes.append([x, y, w, h])
                confidences.append(float(confidence))
                class_ids.append(class_id)
    
    # NMS去重
    indices = cv2.dnn.NMSBoxes(boxes, confidences, conf_threshold, nms_threshold)
    
    result_boxes = []
    result_confidences = []
    result_class_ids = []
    
    if len(indices) > 0:
        for i in indices.flatten():
            result_boxes.append(boxes[i])
            result_confidences.append(confidences[i])
            result_class_ids.append(class_ids[i])
    
    return result_boxes, result_confidences, result_class_ids
核心要点: YOLO输出的坐标是归一化的(0-1之间),需要乘以原始图像的宽高才能得到像素坐标。另外,NMS的阈值我一般设0.4-0.5,太低了会漏检,太高了会有重复框。

1.4 完整检测流程:把一切串起来

好了,现在咱们把上面所有步骤拼成一个完整的函数。这也是我实际项目中用的模板:

def detect_objects(image_path, model_path='yolo_files'):
    # 1. 读取图片
    img = cv2.imread(image_path)
    if img is None:
        print(f'无法读取图片: {image_path}')
        return None
    
    height, width = img.shape[:2]
    
    # 2. 加载模型(只加载一次,实际项目中会缓存)
    net = cv2.dnn.readNet(
        f'{model_path}/yolov4.weights',
        f'{model_path}/yolov4.cfg'
    )
    
    # 3. 预处理
    blob = cv2.dnn.blobFromImage(img, 1/255.0, (416, 416), swapRB=True)
    net.setInput(blob)
    
    # 4. 推理
    layer_names = net.getLayerNames()
    output_layers = [layer_names[i - 1] for i in net.getUnconnectedOutLayers()]
    outputs = net.forward(output_layers)
    
    # 5. 后处理
    boxes, confidences, class_ids = post_process(outputs, width, height)
    
    # 6. 加载类别名称
    with open(f'{model_path}/coco.names', 'r') as f:
        classes = [line.strip() for line in f.readlines()]
    
    # 7. 绘制结果
    for i in range(len(boxes)):
        x, y, w, h = boxes[i]
        label = f'{classes[class_ids[i]]}: {confidences[i]:.2f}'
        
        # 画框
        cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)
        # 写标签
        cv2.putText(img, label, (x, y - 10), 
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)
    
    return img

# 测试
result_img = detect_objects('test.jpg')
if result_img is not None:
    cv2.imshow('Detection Result', result_img)
    cv2.waitKey(0)
    cv2.destroyAllWindows()
我的建议: 实际项目中,模型加载不要放在检测函数里,否则每检测一张图就加载一次模型,太慢了。我一般会在程序启动时加载一次,然后复用。

1.5 常见问题与避坑指南

最后,分享几个我实际踩过的坑:

  • 模型文件版本不匹配:.weights和.cfg必须来自同一个YOLO版本。我曾经混用过v3的cfg和v4的weights,结果检测结果全是乱的。
  • 输入尺寸不一致:训练时用的416x416,推理时也必须是416x416。别想着用608x608能提高精度——模型没训练过那个尺寸,反而会变差。
  • 置信度阈值设得太低:新手总想把阈值设低一点,生怕漏检。结果画出来的框比目标还多。我一般从0.5开始调,根据实际效果上下浮动。
  • 忘记swapRB:这个前面说过了,BGR和RGB的坑,几乎每个人都会踩一次。

嗯,这一章的内容就到这儿。环境搭好了,流程也捋清楚了。下一章咱们会深入聊聊如何调试检测结果——比如怎么可视化中间层的输出、怎么分析漏检和误检的原因。到时候见。