3. 模型加载与基础推理:cv2.dnn.readNet()详解、blobFromImage参数调优、前向传播与输出解析
好,到了真正动手跑模型的时候了。前面两章我们把环境搭好、把模型文件准备好,现在要做的就是把YOLO加载进内存,让它真正“看”到一张图片。
这一章我会带你走完三个核心步骤:加载网络、预处理图像、执行推理并解析结果。每一步都有坑,我当年踩过的,今天帮你填平。
3.1 cv2.dnn.readNet():加载模型的三种姿势
OpenCV的dnn模块支持多种后端。加载模型时,你需要告诉它:模型结构文件在哪、权重文件在哪、用什么框架格式。
核心API签名:
net = cv2.dnn.readNet(model, config=None, framework=None)
参数说明:
- model:权重文件路径(.weights / .caffemodel / .onnx)
- config:结构文件路径(.cfg / .prototxt),可选
- framework:显式指定框架,不指定则自动推断
实际项目中,我遇到过三种常见场景:
| 场景 | 文件组合 | 调用方式 |
|---|---|---|
| Darknet(YOLOv3/v4) | .cfg + .weights | cv2.dnn.readNet('yolov3.weights', 'yolov3.cfg') |
| ONNX(YOLOv5/v8导出) | 单文件 .onnx | cv2.dnn.readNet('yolov5s.onnx') |
| Caffe(老模型) | .prototxt + .caffemodel | cv2.dnn.readNet('model.caffemodel', 'deploy.prototxt') |
我个人习惯用ONNX格式。为什么?因为一个文件搞定,不用操心cfg解析的兼容性问题。而且ONNX的算子优化做得更好,推理速度通常比直接加载Darknet快10%~20%。
我曾经踩过的坑:用readNet加载Darknet模型时,如果cfg文件里写了batch=64,OpenCV会按这个batch size分配内存。你推理单张图时,内存占用直接飙到几个G。解决办法:把cfg里的batch和subdivisions都改成1,或者直接用ONNX。
3.2 blobFromImage:图像预处理的艺术
模型加载完了,下一步是把图像转成模型能吃的格式。这个函数叫blobFromImage,说白了就是把一张HWC的图片,变成NCHW的4维张量。
blob = cv2.dnn.blobFromImage(
image, # 输入图像,BGR格式
scalefactor=1.0, # 缩放因子,通常1/255
size=(416, 416), # 目标尺寸
mean=(0,0,0), # 减均值
swapRB=True, # 是否交换R和B通道
crop=False # 是否中心裁剪
)
每个参数都有讲究,我一个个说。
3.2.1 scalefactor:归一化怎么做?
YOLO训练时,输入像素值被归一化到[0,1]。所以推理时也要做同样的事。scalefactor=1.0/255.0就是最常见的做法。
但注意:如果你加载的是ONNX模型,有些导出工具已经把归一化写进了模型里。这时候你再除以255,等于归一化了两次,结果全错。
我的经验:先试scalefactor=1.0,看输出置信度是否正常。如果全是0.01以下的低分,再改成1.0/255.0。ONNX模型通常不需要手动归一化。
3.2.2 size:输入分辨率怎么选?
YOLO的输入尺寸通常是32的倍数。常见选项:
- 320×320:轻量,适合边缘设备,小目标容易漏
- 416×416:YOLOv3/v4默认,速度和精度平衡
- 640×640:YOLOv5/v8默认,精度高,速度慢
你想想看,分辨率越大,模型看到的细节越多,但计算量是平方级增长的。从416到640,像素数多了2.4倍,推理时间也差不多翻倍。
3.2.3 swapRB与mean:颜色空间的事
OpenCV读图是BGR顺序,但YOLO训练时用的是RGB。所以swapRB=True必须打开,否则颜色通道错乱,模型会认不出物体。
mean参数用来减均值。YOLO官方代码里没有减均值操作,所以设成(0,0,0)就行。如果你用其他预训练模型(比如SSD),可能需要减(104, 117, 123)。
3.3 前向传播:让模型跑起来
预处理完,把blob塞进网络,调用forward。这一步其实很简单:
net.setInput(blob)
outputs = net.forward(outputNames)
但这里有个关键问题:outputNames是什么?
YOLO模型有多个输出层,每个输出层负责检测不同尺度的物体。以YOLOv3为例,它有3个输出层,分别对应13×13、26×26、52×52的特征图。
获取输出层名称的标准做法:
layerNames = net.getLayerNames()
outputNames = [layerNames[i - 1] for i in net.getUnconnectedOutLayers()]
这段代码的意思是:找到所有没有连接到下一层的层——这些就是输出层。我刚开始做的时候直接传空字符串net.forward(),结果返回的是最后一层的输出,漏掉了两个尺度,小目标一个都检测不到。
正确做法:永远用getUnconnectedOutLayers()获取输出层名称,不要硬编码。
3.4 输出解析:从张量到边界框
前向传播返回的是一个列表,每个元素是一个numpy数组,形状为(1, 255, grid_h, grid_w)。为什么是255?因为每个格子预测3个anchor,每个anchor有85个值(4个坐标 + 1个置信度 + 80个类别概率)。
解析流程我总结成五步:
- 遍历每个输出层,拿到特征图
- 遍历每个格子,拿到预测值
- 解析坐标:中心点(x,y)、宽高(w,h)都是相对于格子尺寸的偏移,需要还原到原图坐标
- 计算置信度:物体置信度 × 类别概率最大值
- 过滤低分框:只保留置信度大于阈值的框
核心代码片段:
for output in outputs:
for detection in output:
scores = detection[5:]
classId = np.argmax(scores)
confidence = scores[classId]
if confidence > confThreshold:
# 还原坐标到原图尺寸
centerX = int(detection[0] * w)
centerY = int(detection[1] * h)
width = int(detection[2] * w)
height = int(detection[3] * h)
left = int(centerX - width / 2)
top = int(centerY - height / 2)
boxes.append([left, top, width, height])
confidences.append(float(confidence))
classIds.append(classId)
这里要注意:detection[0]到detection[3]是归一化后的坐标,范围[0,1]。乘以原图宽高才能得到像素坐标。
3.5 NMS:去掉重复框
模型会在同一个物体周围预测多个框。我们需要用非极大值抑制(NMS)来合并它们。
indices = cv2.dnn.NMSBoxes(boxes, confidences, confThreshold, nmsThreshold)
两个阈值怎么调?
- confThreshold:置信度阈值,我一般设0.5。设高了漏检,设低了误检多。
- nmsThreshold:IoU阈值,默认0.4。设大了框叠在一起,设小了同一个物体出多个框。
调参建议:先设confThreshold=0.25,nmsThreshold=0.5,看效果再微调。我做过一个项目,目标遮挡严重,最后把nmsThreshold降到0.3才把重叠框压下去。
3.6 完整推理流程SVG
下面这张图把整个流程串起来了,你可以对照着看:
3.7 完整代码示例
最后,给你一个可以直接跑的完整脚本。把模型路径换成你的文件就行。
import cv2
import numpy as np
# 1. 加载模型
net = cv2.dnn.readNet('yolov5s.onnx')
# 2. 读取图像
img = cv2.imread('test.jpg')
h, w = img.shape[:2]
# 3. 预处理
blob = cv2.dnn.blobFromImage(img, scalefactor=1.0/255.0,
size=(640, 640), swapRB=True)
# 4. 前向传播
net.setInput(blob)
layerNames = net.getLayerNames()
outputNames = [layerNames[i - 1] for i in net.getUnconnectedOutLayers()]
outputs = net.forward(outputNames)
# 5. 解析输出
boxes, confidences, classIds = [], [], []
for output in outputs:
for detection in output:
scores = detection[5:]
classId = np.argmax(scores)
confidence = scores[classId]
if confidence > 0.5:
cx, cy, bw, bh = detection[:4] * np.array([w, h, w, h])
left = int(cx - bw / 2)
top = int(cy - bh / 2)
boxes.append([left, top, int(bw), int(bh)])
confidences.append(float(confidence))
classIds.append(int(classId))
# 6. NMS
indices = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
# 7. 绘制结果
for i in indices.flatten():
x, y, bw, bh = boxes[i]
cv2.rectangle(img, (x, y), (x+bw, y+bh), (0, 255, 0), 2)
label = f'{classIds[i]}: {confidences[i]:.2f}'
cv2.putText(img, label, (x, y-5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
cv2.imshow('Result', img)
cv2.waitKey(0)
跑起来之后做什么?试试换不同尺寸的输入,对比推理时间。你会发现416×416比640×640快一倍,但小目标检测率明显下降。这就是精度和速度的trade-off,实际项目中要根据场景来选。