
上篇回顾
第十一篇讲 VideoCapture / VideoWriter / 背景建模 / 丢帧治理,核心结论是:属性不是测量值 。CAP_PROP_FPS 返回的是按 fps 线性算出来的数,不是节拍;VideoWriter.get() 在 5.0.0 上恒为 -1,那道门禁形同虚设。
这一篇延续同一条方法论:API 名字和文档不等于运行行为。区别在于,第十一篇的偏差在元数据,这一篇的偏差直接决定你的代码能不能跑起来。
差别有多大?我原本按 4.x 的经验写了段检测代码,第一行就崩:
AttributeError: module 'cv2' has no attribute 'CascadeClassifier'
不是"需要先下载模型文件",是整个类不存在。
本篇要解决的痛点
- 网上所有 OpenCV dnn 教程都是 4.x 的写法,
cv2.dnn.Target.CPU、setLogLevel、readNetFromCaffe在 5.0.0 上全部失效,照抄必崩。 - 知道
setPreferableTarget能加速,但不知道它在 5.0.0 上到底还有没有用。 - 模型
readNet成功不等于能forward。我手上 13 个检测模型,能真正跑起来的前向只有 2 个。 NMSBoxes明明有top_k参数,但传进去的返回数跟文档对不上。- 看到 Canny 一帧 0.47 ms,就想问:那到底什么时候还值得用传统算子?
能力承诺
读完这一篇你会拿到:
- 一张 5.0.0 的
dnn/objdetectAPI 生死表,附readNet失败时的真实报错文本。 blobFromImage的全部参数语义,特别是mean会跟着swapRB一起换位这个坑。- 一个 13 模型 × 4 引擎 = 52 格的兼容性矩阵,以及为什么只有 2 个模型能跑。
- 一套能用的推理代码骨架:选引擎、warmup、计时、逐层剖析。
NMSBoxes家族的阈值边界、dtype陷阱和top_k异常实测。QRCodeEncoder/QRCodeDetector闭环与失效边界(模块像素数实测)。- 一张传统 vs DNN 的分工表,每一格都有本机耗时支撑。
一、5.0.0 的 dnn 已经不是 4.x 的 dnn 了
1.1 枚举类整体消失
4.x 的写法是 cv2.dnn.Target.CPU、cv2.dnn.Backend.OPENCV。5.0.0 里这四个符号一个都不存在:
python
import cv2
for name in ("Target", "Backend", "DNN_BACKEND", "DNN_TARGET"):
print(name, hasattr(cv2.dnn, name))
text
Target False
Backend False
DNN_BACKEND False
DNN_TARGET False
改成 dnn 模块下的顶层常量 DNN_TARGET_* / DNN_BACKEND_*:
python
import cv2
dnn = cv2.dnn
targets = {n: getattr(dnn, n) for n in dir(dnn) if n.startswith("DNN_TARGET_")}
for k, v in sorted(targets.items(), key=lambda kv: kv[1]):
print(k, v)
实测全表(本机输出):
| 常量 | 值 | 常量 | 值 | |
|---|---|---|---|---|
DNN_TARGET_CPU |
0 | DNN_TARGET_HDDL |
8 | |
DNN_TARGET_OPENCL |
1 | DNN_TARGET_NPU |
9 | |
DNN_TARGET_OPENCL_FP16 |
2 | DNN_TARGET_CPU_FP16 |
10 | |
DNN_TARGET_MYRIAD |
3 | DNN_TARGET_FPGA |
5 | |
DNN_TARGET_VULKAN |
4 | DNN_TARGET_CUDA |
6 | |
DNN_TARGET_CUDA_FP16 |
7 |
python
import cv2
dnn = cv2.dnn
backends = {n: getattr(dnn, n) for n in dir(dnn) if n.startswith("DNN_BACKEND_")}
for k, v in sorted(backends.items(), key=lambda kv: kv[1]):
print(k, v)
text
DNN_BACKEND_DEFAULT 0
DNN_BACKEND_INFERENCE_ENGINE 2
DNN_BACKEND_OPENCV 3
DNN_BACKEND_VKCOM 4
DNN_BACKEND_CUDA 5
DNN_BACKEND_WEBNN 6
DNN_BACKEND_TIMVX 7
DNN_BACKEND_CANN 8
注意 DNN_BACKEND_DEFAULT = 0 和 DNN_BACKEND_INFERENCE_ENGINE = 2 之间跳过了 1 。这个空洞不是笔误,是历史上 Halide 占过的位置(见 1.4)。你如果按 1 猜一个后端,会得到一个不存在的值。
1.2 别问"支持哪些后端",要问"这个后端支持哪些 target"
4.x 有 getAvailableBackends(),5.0.0 没有了,换成 getAvailableTargets(backend)------参数是后端,返回的是该后端可用的 target 列表:
python
import cv2
dnn = cv2.dnn
names = {0: "DEFAULT", 2: "INFERENCE_ENGINE", 3: "OPENCV", 4: "VKCOM",
5: "CUDA", 6: "WEBNN", 7: "TIMVX", 8: "CANN"}
for be in (0, 2, 3, 4, 5, 6, 7, 8):
try:
ts = dnn.getAvailableTargets(be)
except Exception as e:
ts = "ERR %s" % type(e).__name__
print("be=%-2d %-18s -> %s" % (be, names[be], ts))
text
be=0 DEFAULT -> [1, 2, 0]
be=2 INFERENCE_ENGINE -> []
be=3 OPENCV -> [1, 2, 0]
be=4 VKCOM -> []
be=5 CUDA -> []
be=6 WEBNN -> []
be=7 TIMVX -> []
be=8 CANN -> []
为什么这张表比"我装了什么后端"更有用 :返回 [1, 2, 0] 是 OPENCL / OPENCL_FP16 / CPU,说明本机 OpenCV 后端编译进了 OpenCL(getBuildInformation 里 OpenCL: YES (NVD3D11) 与之一致)。返回空元组的后端不是"不支持",是本机不可用------装了 CUDA 工具链不等于这个 wheel 带 CUDA 后端。
1.3 DNN_LAYOUT_* 搬家到 cv2 顶层
python
import cv2
print(cv2.DNN_LAYOUT_NCHW, cv2.DNN_LAYOUT_NHWC, cv2.DNN_LAYOUT_PLANAR)
print(hasattr(cv2.dnn, "DNN_LAYOUT_NCHW"))
text
2 4 6
False
全表:UNKNOWN=0, ND=1, NCHW=2, NCDHW=3, NHWC=4, NDHWC=5, PLANAR=6, BLOCK=7。
这是个纯粹的破口迁移 :cv2.dnn.DNN_LAYOUT_NCHW → cv2.DNN_LAYOUT_NCHW。如果你 4.x 代码里用的是 from cv2.dnn import DNN_LAYOUT_NCHW,5.0.0 上会在 import 阶段直接断。
1.4 消失的符号清单
我逐个 hasattr 核对过,以下 4.x 常用符号在 5.0.0 上全部不存在:
| 4.x 符号 | 5.0.0 | 替代 |
|---|---|---|
dnn.DNN_BACKEND_HALIDE |
❌ | 已移除,无替代 |
dnn.DNN_TARGET_HALIDE |
❌ | 已移除,无替代 |
dnn.getAvailableBackends() |
❌ | dnn.getAvailableTargets(backend) |
dnn.getPerfProfile()(模块级) |
❌ | Net.getPerfProfile()(实例方法) |
dnn.setPerfProfile() |
❌ | dnn.DNN_PROFILE_* 常量 |
dnn.setLogLevel() |
❌ | dnn.DNN_TRACE_* 常量 |
dnn.readNetFromCaffe() |
❌ | 已移除 |
dnn.readNetFromTorch() |
❌ | 已移除 |
dnn.Target / dnn.Backend |
❌ | DNN_TARGET_* / DNN_BACKEND_* |
Halide 被彻底拿掉了 ,不是隐藏。2015~2019 年间大量 OpenCV DNN 教程还在用 DNN_BACKEND_HALIDE,在 5.0.0 上全是 AttributeError。
日志控制换了个思路,DNN_TRACE_* 是常量而非函数:
python
import cv2
dnn = cv2.dnn
for n in ("DNN_TRACE_NONE", "DNN_TRACE_ALL", "DNN_TRACE_OP",
"DNN_PROFILE_NONE", "DNN_PROFILE_SUMMARY", "DNN_PROFILE_DETAILED"):
print(n, getattr(dnn, n))
text
DNN_TRACE_NONE 0
DNN_TRACE_ALL 1
DNN_TRACE_OP 2
DNN_PROFILE_NONE 0
DNN_PROFILE_SUMMARY 1
DNN_PROFILE_DETAILED 2
但注意这组常量管的是 dnn 模块自己的 trace ,不是 OpenCV 全局日志。全局日志要走 cv2.utils.logging(第四章实测用到)。
C++ 侧对照(本系列读者多数是 C++ 上位机开发者,所以每章给一段对照):
cpp
// 未编译:本机没有 OpenCV 5.0.0 C++ SDK,本块未经编译验证,
// 仅为第一章 Python 侧实测结论的 C++ 对照写法,请以你自己的 SDK 头文件为准。
#include <opencv2/core.hpp>
#include <opencv2/dnn.hpp>
// 5.0.0:Python 侧 dnn.Backend / dnn.Target 两个枚举类整体消失(1.1 实测),
// 对应到 C++ 就是 cv::dnn::Backend / cv::dnn::Target 这两个 enum class 也不再提供,
// 改为 DNN_BACKEND_* / DNN_TARGET_* 这组扁平常量。
static constexpr cv::dnn::Backend kBackend = cv::dnn::DNN_BACKEND_OPENCV;
static constexpr cv::dnn::Target kTarget = cv::dnn::DNN_TARGET_CPU;
void configure(cv::dnn::Net& net) {
net.setPreferableBackend(kBackend);
// ❗Python 侧实测:新 graph engine 下 setPreferableTarget 是 no-op(3.2),
// 不抛异常、无数值变化、只在 stderr 打一行 WARN。
// C++ 侧若行为一致,这里同样是无效调用;不要靠它换性能。
net.setPreferableTarget(kTarget);
}
为什么给这段 :1.1 那个"Backend/Target 枚举类消失"是头文件层面的改动 ,Python 绑定只是把它暴露出来了。所以 C++ 迁移时会遇到同一条编译错误------cv::dnn::DNN_TARGET_OPENCL_FP16 还能编过(它是常量),但 cv::dnn::Target t = ... 这类写法会直接编译失败。换成扁平常量能编过,不代表它有效果,这一点和 3.2 的 Python 实测是同一个坑。
1.5 新增了什么
| 新符号 | 用途 |
|---|---|
dnn.readNetFromTFLite |
TFLite 读取 |
dnn.readNetFromModelOptimizer / dnn.Net_readFromModelOptimizer |
Intel Model Optimizer 产物 |
dnn.readTensorFromONNX |
只读张量不建图------只要权重不要网络时用 |
dnn.blobFromImageWithParams |
结构化入参版 blobFromImage |
dnn.Image2BlobParams |
上面那个的参数结构体 |
dnn.imagesFromBlob |
blob 逆变换回图像 |
dnn.ENGINE_AUTO / CLASSIC / NEW / ORT |
5.0.0 新 DNN 引擎选择(第三章主角) |
dnn.NMSBoxesBatched |
按类分组抑制 |
dnn.NMSBoxesRotated |
旋转框 NMS |
dnn.softNMSBoxes |
Soft-NMS |
dnn.DNN_MODEL_ONNX/TF/TFLITE/GENERIC |
模型类型标记 |
引擎那组常量的值是 CLASSIC=1, NEW=2, AUTO=3, ORT=4。
还有一类符号要注意 :类的绑定被同时拍到了 cv2 顶层,用 dnn_ 前缀:
python
import cv2
print(hasattr(cv2, "dnn_Net"), hasattr(cv2, "dnn_Image2BlobParams"),
hasattr(cv2, "dnn_ClassificationModel"), hasattr(cv2, "dnn_Tokenizer"))
text
True True True True
cv2.dnn_ClassificationModel 这些在 5.0.0 上是存在的。所以 hasattr(cv2.dnn, "ClassificationModel") 之外,cv2 顶层还有一条通路------写自动化兼容性检查时两条都要试。
1.6 objdetect:级联和 HOG 都没了
这是本篇最硬的一条 API 事实:
python
import cv2
for name in ("CascadeClassifier", "HOGDescriptor", "QRCodeDetector",
"QRCodeEncoder", "FaceDetectorYN",
"GraphicalCodeDetector", "QRCodeDetectorAruco"):
print(name, hasattr(cv2, name))
text
CascadeClassifier False
HOGDescriptor False
QRCodeDetector True
QRCodeEncoder True
FaceDetectorYN True
GraphicalCodeDetector True
QRCodeDetectorAruco True
CascadeClassifier 和 HOGDescriptor 从 5.0.0 的 Python 绑定里整体移除。
配套地,opencv-python 也不再捆绑任何级联 XML:
python
import cv2.data
import os
d = cv2.data.haarcascades
entries = os.listdir(d)
xml = [e for e in entries if e.endswith(".xml")]
print("目录:", d)
print("条目数:", len(entries), entries)
print("haarcascade*.xml 数量:", len(xml))
text
目录: C:\Users\Richard\AppData\Roaming\Python\Python313\site-packages\cv2\data
条目数: 2 ['__init__.py', '__pycache__']
haarcascade*.xml 数量: 0
cv2.data 目录还在 ,但里面只剩一个 73 字节的 __init__.py。所以 4.x 教程里 cv2.data.haarcascades + "haarcascade_frontalface_default.xml" 这种写法,在 5.0.0 上会拼出一个不存在的路径,然后 readNet 报一个看起来毫不相关的错(见 1.8)。
为什么这件事值得单独一节 :Haar 级联和 HOG 是很多"不学 DNN 也能做检测"的教程的落脚点。它们消失意味着"传统检测器"这条退路在 5.0.0 的 Python 侧只剩 QR 这一族 (QRCodeDetector / QRCodeDetectorAruco / GraphicalCodeDetector / FaceDetectorYN)。第六章会拿 QR 实测这条退路的边界。
真要用级联,目前的可行路径是装 opencv-contrib-python 的旧版本、或回到 4.x、或换 ONNX Runtime。前两者是降级,最后一个是换引擎。
1.7 编译信息里两个值得盯的字段
text
Intel IPP: 2026.0.0 [2026.0.0]
Intel IPP IW: sources (2026.0.0)
DNN MLAS: NO (no ASM compiler available for AMD64)
OpenCL: YES (NVD3D11)
Flatbuffers: builtin/3rdparty (25.9.23)
DNN MLAS: NO 值得停一下。MLAS 是 Intel 的矩阵加速库,通常负责 fp16/int8 的低精度卷积内核。它是 NO 的理由写得很直白:构建环境没有 AMD64 的汇编编译器。
对本文的直接影响:别指望 DNN_TARGET_CPU_FP16 在这个 wheel 上快。低精度路径依赖 MLAS 内核,而它没编进来。这不是说一定不能跑,而是这条路径没有加速基础------第四章会用实测耗时验证这件事,而不是靠猜。
Flatbuffers 存在则说明 readNetFromTFLite 是真的编进来了,不是空壳。
1.8 readNet 失败时的真实报错
模型读不进来时,5.0.0 的报错带 C++ 源文件路径和行号:
python
import cv2, os
bad = os.path.join(os.environ.get("TEMP", "."), "__no_such_model.onnx")
try:
cv2.dnn.readNet(bad)
except Exception as e:
print(str(e)[:200])
text
OpenCV(5.0.0) ...\dnn\src\onnx\onnx_importer2.cpp:341: error: (-5:Bad argument) Can't read ONNX file: ...\__no_such_model.onnx
文件存在但内容不是合法模型:
text
OpenCV(5.0.0) ...\onnx_importer2.cpp:346: error: (-210:Unsupported format or combination of formats) Failed to parse ONNX model: ...
TFLite 入口的报错又不一样:
text
OpenCV(5.0.0) ...\tflite\tflite_importer.cpp:142: error: (-2:Unspecified error) DNN/TFLite: model is incorrect
为什么值得记住报错文本 :路径不存在是 (-5:Bad argument),内容非法是 (-210:Unsupported format),两者排查方向完全相反。-5 查路径和权限,-210 查导出参数(opset、算子集、量化节点)。我在第三章会看到第三类:-215 Assertion 和 -201 Incorrect size,那是读进来了但图有问题。
1.9 本机有多少模型可测
先确认这台机器上到底有没有能实测的模型,否则后面全是空谈:
python
import glob, os
pats = [
r"D:\WorkSpace\**\*.onnx",
]
seen = []
for p in pats:
seen += glob.glob(p, recursive=True)
print("命中模型文件数:", len(seen))
实测 46 个 .onnx。这个数字是后面所有"真实推理"结论的前提------没有本地模型,就只能写代码不能验证,这一篇和前面几篇最大的区别就在这。
二、blobFromImage:公式、通道顺序与 mean 换位
2.1 签名与公式
python
import cv2
print(cv2.dnn.blobFromImage.__doc__.strip().split("\n")[0])
text
blobFromImage(image[, scalefactor[, size[, mean[, swapRB[, crop[, ddepth]]]]]]) -> retval
官方 doc 里有一句容易被跳过的 @note:
text
The order and usage of `scalefactor` and `mean` are (input - mean) * scalefactor.
先减后乘 。这个顺序决定了 mean 该填 0--255 还是 0--1 量级。实测一张 B=1、G=2、R=3 的纯色图:
python
import cv2, numpy as np
img = np.full((10, 20, 3), 0, np.uint8)
img[:, :, 0] = 1; img[:, :, 1] = 2; img[:, :, 2] = 3
def ch0(**kw):
return float(cv2.dnn.blobFromImage(img, **kw)[0, 0, 0, 0])
print("不传 scalefactor :", ch0())
print("scale=1.0 :", ch0(scalefactor=1.0))
print("scale=0.5 :", ch0(scalefactor=0.5))
print("scale=1/255 :", ch0(scalefactor=1 / 255))
print("scale=1/128 :", ch0(scalefactor=1 / 128))
text
不传 scalefactor : 1.0
scale=1.0 : 1.0
scale=0.5 : 0.5
scale=1/255 : 0.00392157
scale=1/128 : 0.0078125
为什么这个顺序很要紧 :(input - mean) * scalefactor 意味着如果你按"先归一化再减均值"的直觉写成 mean=(0.485*255, ...),实际得到的是 (input - 123.7) / 255,而不是你以为的 (input/255 - 0.485)。两种写法差一个 255 倍的量级,网络照样前向成功、照样吐出概率------只是全是垃圾。这类 bug 没有任何异常可以抓。
2.2 输出是 NCHW,通道数跟着输入走
python
import cv2, numpy as np
img = np.full((10, 20, 3), 7, np.uint8)
gray = np.full((10, 20), 7, np.uint8)
bgra = np.full((10, 20, 4), 7, np.uint8)
print("3 通道 :", cv2.dnn.blobFromImage(img).shape)
print("灰度 :", cv2.dnn.blobFromImage(gray).shape)
print("BGRA :", cv2.dnn.blobFromImage(bgra).shape)
print("批量 2 :", cv2.dnn.blobFromImages([img, img]).shape)
print("size :", cv2.dnn.blobFromImage(img, size=(5, 5)).shape)
text
3 通道 : (1, 3, 10, 20)
灰度 : (1, 1, 5, 5)
BGRA : (1, 4, 5, 5)
批量 2 : (2, 3, 10, 20)
size : (1, 3, 5, 5)
注意最后两个的 width/height:默认 size 缺失时不是 (5,5),而是保留了原图 10×20。批量的第一维是图片张数。
ddepth 只认两种:
python
for dd in (cv2.CV_32F, cv2.CV_8U, cv2.CV_64F, cv2.CV_16F, cv2.CV_16S):
try:
b = cv2.dnn.blobFromImage(img, size=(5, 5), ddepth=dd)
print("ddepth=%-2d -> %s %s" % (dd, b.shape, b.dtype))
except Exception as e:
print("ddepth=%-2d -> !! %s" % (dd, str(e).split("error:")[-1][:60]))
text
ddepth=5 -> (1, 3, 5, 5) float32
ddepth=0 -> (1, 3, 5, 5) uint8
ddepth=6 -> !! (-2:Unspecified error) in function 'void __cdecl ... blobFromImagesWithParamsImpl
ddepth=7 -> !! (-2:Unspecified error) in function 'void __cdecl ... blobFromImagesWithParamsImpl
ddepth=3 -> !! (-2:Unspecified error) in function 'void __cdecl ... blobFromImagesWithParamsImpl
只有 CV_32F 和 CV_8U 被接受 。CV_64F / CV_16F / CV_16S 全部抛异常。5.0.0 没有 layout 参数了:
python
try:
cv2.dnn.blobFromImage(img, layout=cv2.DNN_LAYOUT_NCHW)
except TypeError as e:
print("TypeError:", str(e)[:120])
text
TypeError: blobFromImage() got an unexpected keyword argument 'layout'
如果你需要 NHWC 输出 :blobFromImage 给不了,只能拿到 NCHW 再自己 transpose。这个限制在写多模型一致性对比代码时会顶到。
2.3 通道顺序:默认 BGR,swapRB=True 才是 RGB
沿用 B=1、G=2、R=3 的图:
python
import cv2, numpy as np
img = np.zeros((10, 20, 3), np.uint8)
img[:, :, 0] = 1; img[:, :, 1] = 2; img[:, :, 2] = 3
def chs(**kw):
b = cv2.dnn.blobFromImage(img, size=(2, 2), **kw)
return [float(b[0, c, 0, 0]) for c in range(3)]
print("默认 ch0/ch1/ch2 :", chs())
print("swapRB ch0/ch1/ch2 :", chs(swapRB=True))
text
默认 ch0/ch1/ch2 : [1.0, 1.0, 1.0]
swapRB ch0/ch1/ch2 : [3.0, 3.0, 3.0]
默认把内存里的 BGR 原样搬到 blob 的 ch0/ch1/ch2;swapRB=True 把首尾对调,得到 RGB。
为什么这件事值得反复强调 :OpenCV 的 Mat 是 BGR,而几乎所有公开的预训练模型(YOLO、MobileNet、ResNet)是在 RGB 上训的。喂反了的典型症状不是报错,而是精度悄悄掉几个点 ------你会以为是模型不行、去换模型、去调阈值,方向全错。判据很简单:拿一张纯红图,swapRB=True 之后 ch0 应该是 255 附近;如果 ch0 接近 0,说明你没开 swap。
2.4 mean 会跟着 swapRB 一起换位 ------ 本章最容易踩的坑
python
import cv2, numpy as np
img = np.zeros((10, 20, 3), np.uint8)
img[:, :, 0] = 1; img[:, :, 1] = 2; img[:, :, 2] = 3
def ch0(**kw):
return float(cv2.dnn.blobFromImage(img, size=(2, 2), **kw)[0, 0, 0, 0])
print("mean=(10,20,30) :", ch0(mean=(10, 20, 30)))
print("mean=(10,20,30) swapRB=True:", ch0(mean=(10, 20, 30), swapRB=True))
print("mean=10.0 :", ch0(mean=10.0))
print("mean=None :", ch0(mean=None))
text
mean=(10,20,30) : -9.0
mean=(10,20,30) swapRB=True: -7.0
mean=10.0 : -9.0
mean=None : 1.0
拆解一下这两个数:
- 不开 swapRB:
(1 - 10) * 1.0 = -9.0,ch0 拿到 B=1 减 B 序 mean 的 10。 - 开 swapRB:
(3 - 10) * 1.0 = -7.0。ch0 变成了 R=3,但减的仍然是 10。
也就是说 mean 跟着通道一起换位了。你按 BGR 顺序传 (10, 20, 30),开 swapRB 之后它实际按 (mean_R, mean_G, mean_B) 解释。这正是 doc 里那句:
Values are intended to be in (mean-R, mean-G, mean-B) order if image has BGR ordering and swapRB is true.
为什么这个坑特别阴 :它只在 swapRB=True 时生效,而 swapRB=True 恰恰是"喂预训练模型"的标配。你会同时打开两个开关,然后 mean 悄悄错位。
ImageNet 的 mean 是 RGB 序的 (0.485, 0.456, 0.406) * 255 ≈ (123.7, 116.3, 103.5)。你的图是 BGR。结论:mean 用 RGB 序传,配 swapRB=True,让 OpenCV 帮你换位,这才是自洽的组合:
python
mean = (0.485 * 255, 0.456 * 255, 0.406 * 255) # RGB 序
blob = cv2.dnn.blobFromImage(bgr_img, scalefactor=1.0,
size=(224, 224), mean=mean, swapRB=True)
mean 传标量时三通道都减同一个数(实测 mean=10.0 → -9.0,三通道一致),适合已经转灰度的场景。传 None 等于不减。
2.5 crop=True 是"先缩放到能盖住,再中心裁"
python
import cv2, numpy as np
img = np.full((10, 20, 3), 9, np.uint8) # 竖长图 20x10
for crop in (False, True):
b = cv2.dnn.blobFromImage(img, size=(5, 5), crop=crop)
print("crop=%-5s shape=%s 唯一值=%s" % (crop, b.shape, np.unique(b)))
text
crop=False shape=(1, 3, 5, 5) 唯一值=[9.0]
crop=True shape=(1, 3, 5, 5) 唯一值=[9.0]
纯色图两种模式输出一样,看不出区别。要区分得用非纯色图,比如左边黑右边白:
python
img = np.zeros((10, 20, 3), np.uint8)
img[:, :10] = 255
for crop in (False, True):
b = cv2.dnn.blobFromImage(img, size=(5, 5), crop=crop)
print("crop=%-5s 左半均值=%.1f 右半均值=%.1f"
% (crop, b[0, 0, :, :2].mean(), b[0, 0, :, 3:].mean()))
非纯色图上 crop=True 只取中心区域,两侧内容被丢掉;crop=False 是整图压进去。
为什么这个区别会毁掉检测结果 :crop=True 等于把画面外的物体移出了视野 。工业检测里如果目标是靠边定位的,crop=True 会直接把目标裁掉,然后你在输出里看不到它,还以为是模型漏检。默认 crop=False 是安全的,需要 letterbox 效果时自己在外面做:
python
def letterbox(img, size, pad=114):
h, w = img.shape[:2]
r = min(size / w, size / h)
nw, nh = int(round(w * r)), int(round(h * r))
resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
canvas = np.full((size, size, 3), pad, np.uint8)
canvas[(size - nh) // 2:(size - nh) // 2 + nh,
(size - nw) // 2:(size - nw) // 2 + nw] = resized
return canvas
crop=False 会破坏长宽比 (整图硬压成方块)。这一条和 crop=True 丢掉边缘,是两个独立的坑。
2.6 BGRA 的第 4 通道参与 mean 但不进 blob
python
import cv2, numpy as np
bgra = np.zeros((10, 20, 4), np.uint8)
bgra[:, :, 0] = 1; bgra[:, :, 1] = 2; bgra[:, :, 2] = 3; bgra[:, :, 3] = 4
print("shape:", cv2.dnn.blobFromImage(bgra, size=(2, 2)).shape)
print("swapRB 后 ch0/1/2:",
[float(cv2.dnn.blobFromImage(bgra, size=(2, 2), swapRB=True)[0, c, 0, 0])
for c in range(3)])
b = cv2.dnn.blobFromImage(bgra, size=(2, 2), mean=(0, 0, 0, 255))
print("mean=(0,0,0,255) 后 ch0/1/2:",
[float(b[0, c, 0, 0]) for c in range(3)])
text
shape: (1, 4, 5, 5)
swapRB 后 ch0/1/2: [3.0, 3.0, 3.0]
mean=(0,0,0,255) 后 ch0/1/2: [1.0, 1.0, 1.0]
BGRA 输入得到 (1, 4, H, W) 的 blob------4 通道原样保留 。swapRB=True 只对调前三个。mean 传 4 元组时第 4 个值只作用在 alpha 通道上,前三通道不受影响。
为什么要注意 :很多相机 SDK 直接给你 BGRA。如果你不转 BGR 就喂进去,网络拿到的是 (1,4,H,W) 而模型要 (1,3,H,W),setInput 会因为形状不匹配报错------这个还算好。真正麻烦的是 alpha 通道如果被填成 255 又被当成颜色通道参与卷积,你会得到一个几乎全白输入的诡异结果。
2.7 size 远大于原图:自动放大,不报错
python
import cv2, numpy as np
img = np.zeros((10, 20, 3), np.uint8)
img[:, :, 0] = 1; img[:, :, 1] = 2; img[:, :, 2] = 3
b = cv2.dnn.blobFromImage(img, size=(64, 64))
print("10x20 -> size=(64,64):", b.shape, "唯一值:", np.unique(b))
text
10x20 -> size=(64,64): (1, 3, 64, 64) 唯一值: [1. 2. 3.]
一张 20×10 的图请求 64×64,直接给你 64×64,没有警告。把 10×10 的相机帧喂进要 640×640 的模型,不会报任何错,你只会拿到一个放大糊掉的输入。这在调相机分辨率时特别容易发生------分辨率配错了,模型照跑,输出全空。
2.8 结构化入参:Image2BlobParams
5.0.0 新增了 blobFromImageWithParams,参数收进一个结构体:
python
import cv2
dnn = cv2.dnn
print("可用属性:", [a for a in dir(dnn.Image2BlobParams) if not a.startswith("_")])
text
可用属性: ['blobRectToImageRect', 'blobRectsToImageRects', 'borderValue',
'datalayout', 'ddepth', 'mean', 'paddingmode', 'scalefactor',
'size', 'swapRB']
注意两点:
- 是
scalefactor,不是scale。 - 没有
crop。
python
p = dnn.Image2BlobParams()
try:
p.scale = 0.5
except AttributeError as e:
print("AttributeError:", str(e)[:90])
try:
p.crop = True
except AttributeError as e:
print("AttributeError:", str(e)[:90])
text
AttributeError: 'cv2.dnn.Image2BlobParams' object has no attribute 'scale'
AttributeError: 'cv2.dnn.Image2BlobParams' object has no attribute 'crop'
(两者都带 and no __dict__ for setting new attributes------这是 pybind11 绑定对象不允许动态加属性的典型报错。)
为什么这个结构体值得用 :参数可以先构造好、复用、传给 blobFromImageWithParams,适合循环里反复调用、或者同一套配置要传给多个模型。而 blobFromImage 的位置参数在长参数列表下很容易传错顺序(mean 和 scalefactor 挨着)。
另外结构体多了几个旧接口没有的能力:borderValue + paddingmode(可控的 letterbox 内边距)、datalayout(1.3 节说过它搬到了 cv2 顶层,这里能直接用),以及 blobRectToImageRect / blobRectsToImageRects 两个坐标映射工具。
这两个工具的签名我一开始就看错了,这里把实测结论摆出来。它的真实签名是:
text
blobRectToImageRect(rBlob, size) -> retval # 单个框
blobRectsToImageRects(rBlob, size) -> rImg # 批量框,rBlob 形状 (N,4)
注意第一个参数是框,不是 blob ;第二个参数是原图尺寸 ,也不是框。我最初写成了 blobRectsToImageRects(blob, rects),实测直接抛 TypeError: Can't parse 'rBlob'. Sequence item with index 0 has a wrong type。四个使用细节全是实测踩出来的:
| 细节 | ❌ 我一开始的写法 | ✅ 实测能通的写法 |
|---|---|---|
| 参数顺序 | (blob, rects) |
(rects, (原图宽, 原图高)) |
rBlob dtype |
np.float32 |
np.int32 / np.int64(浮点被拒) |
| 单个版形状 | np.array([[x1,y1,x2,y2]]) |
np.array([x1,y1,x2,y2])(必须扁平 4 元组,传 (1,4) 报 Expected sequence length 4, got 1) |
size 类型 |
cv2.Size(w, h) |
(w, h) 元组(cv2 顶层没有 Size) |
更关键的是:它只做纯 resize 的逆变换,完全无视 letterbox 的 padding。 我用 640×480 的图喂 (640, 640) 实测:
python
import numpy as np, cv2
img = np.full((480, 640, 3), 128, np.uint8)
p = cv2.dnn.Image2BlobParams()
p.size = (640, 640)
p.borderValue = (114, 114, 114)
p.paddingmode = cv2.BORDER_CONSTANT
blob = cv2.dnn.blobFromImageWithParams(img, p) # (1, 3, 640, 640)
r_blob = np.array([[100, 100, 200, 200]], np.int32)
print(p.blobRectsToImageRects(r_blob, (640, 480)).tolist())
text
[[100, 75, 200, 150]]
反推出的公式是 x_img = x_blob * (W_img/W_size)、y_img = y_blob * (H_img/H_size)------横纵各按自己的比例缩放,等价于"无视宽高比直接拉伸" 。可等比 letterbox 的真实几何是 scale = min(640/640, 640/480) = 1.0(不缩放),上下各补 (640-480)/2 = 80,所以那个框的真实图像坐标应该是 [100, 20, 200, 120]。函数在 y 上偏了 55 px。 我又交叉验证了一次:把 borderValue / paddingmode 全去掉(纯拉伸 resize),输出仍是 [100, 75, 200, 150]------证明它压根没读这两个属性。
所以结论要反过来写 :这两个函数只在"纯 resize、无 padding"的预处理下才是对的 ;一旦你用了 borderValue / paddingmode 做 letterbox(而这恰恰是检测模型的标配),它会给你一个不报错、不越界、只是系统性偏移 的结果。这比抛异常危险得多------框全都在图内,你不会发现任何异常,只会在叠加时看到目标整体错位。
还有一个更隐蔽的坑:p.size 没设时它不报错,直接返回 INT_MIN:
text
# p = cv2.dnn.Image2BlobParams() # 完全不配置
# p.blobRectsToImageRects(r_blob, (640, 480))
[[-2147483648, -2147483648, -2147483648, -2147483648]]
-2147483648 就是 INT_MIN(cv::Rect() 的默认值)。这个函数不读 borderValue,也不读 paddingmode,只读 p.size ------所以没设 size 时它没有任何可用的逆变换依据,就用默认值糊过去了。
✅ letterbox 下真正可靠的还原,还是得自己算这四步(这也是我在第五章 NMS 里用的):
python
def blob_rects_to_image_letterbox(boxes, src_w, src_h, net_w, net_h):
"""把 letterbox + 等比缩放后的 blob 框映射回原图坐标。boxes: (N,4)。"""
sc = min(net_w / src_w, net_h / src_h) # 等比缩放比
new_w, new_h = src_w * sc, src_h * sc
pad_x, pad_y = (net_w - new_w) / 2, (net_h - new_h) / 2 # 左右、上下 padding
b = np.asarray(boxes, np.float32).reshape(-1, 4).copy()
b[:, [0, 2]] = (b[:, [0, 2]] - pad_x) / sc # x1, x2
b[:, [1, 3]] = (b[:, [1, 3]] - pad_y) / sc # y1, y2
return b
实测对照(sc = 1.0、pad = (0, 80)):
text
blob[100, 100, 200, 200] -> image [100.0, 20.0, 200.0, 120.0] # 与手算一致
blob[ 0, 0, 10, 10] -> image [ 0.0, -80.0, 10.0, -70.0] # 落在 padding 区,负值要裁掉
blob[300, 400, 500, 639] -> image [300.0, 320.0, 500.0, 559.0] # 贴边框也要裁
注意第二行:padding 区的框会映出负坐标 ,回原图后必须 np.clip 或过滤掉,否则 NMS 统计里会混进一串假框。这也是我在 5.6 节强调"先过滤再 NMS"的原因之一。
mean 在结构体里读回是 4 元组:
python
p = cv2.dnn.Image2BlobParams()
p.mean = (10, 20, 30)
print("读回:", p.mean)
text
读回: (10.0, 20.0, 30.0, 0.0)
传 3 个值,读回 4 个(第 4 个补 0)。如果你后面拿 p.mean 做点积,长度会不匹配。
2.9 逆变换:imagesFromBlob
python
import cv2, numpy as np
img = np.full((10, 20, 3), 7, np.uint8)
back = cv2.dnn.imagesFromBlob(cv2.dnn.blobFromImages([img, img]))
print("数量:", len(back))
print("单张 shape:", back[0].shape)
text
数量: 2
单张 shape: (10, 20, 3)
用来把 NCHW blob 拆回图像数组,做可视化对比时省事。注意它只还原空间布局,不还原 mean 减法和 scalefactor 乘法------那两步不可逆。
三、真实模型推理:13 模型 × 4 引擎 = 52 格矩阵
前两章都是 API 层面的。这一章开始跑真模型------这是我这一篇和前面几篇最大的区别。
3.1 先解决一个 API 问题:怎么选引擎
ENGINE_* 常量在 5.0.0 存在,但 Net 上没有 setEngine 方法:
python
import cv2
dnn = cv2.dnn
print("Net.setEngine 存在 :", hasattr(dnn.Net, "setEngine"))
print("Net 上含 engine 的方法 :", [m for m in dir(dnn.Net) if "ngine" in m])
print("readNet 签名首行 :", dnn.readNet.__doc__.strip().split("\n")[0])
print("ENGINE_* :", {n: getattr(dnn, n) for n in
("ENGINE_CLASSIC", "ENGINE_NEW", "ENGINE_AUTO", "ENGINE_ORT")})
text
Net.setEngine 存在 : False
Net 上含 engine 的方法 : []
readNet 签名首行 : readNet(model[, config[, framework[, engine]]]) -> retval
ENGINE_* : {'ENGINE_CLASSIC': 1, 'ENGINE_NEW': 2, 'ENGINE_AUTO': 3, 'ENGINE_ORT': 4}
引擎是 readNet 的第 4 个位置参数,不是 Net 上的方法:
python
import cv2
dnn = cv2.dnn
net = dnn.readNet("model.onnx", "", "", dnn.ENGINE_ORT) # 第 4 个参数
写成 readNet(path, "", "", engine) 这样,位置参数错一位就会把 engine 传成 framework,然后你会看到一个跟引擎毫无关系的格式报错。
C++ 侧对照:
cpp
// 未编译:本机没有 OpenCV 5.0.0 C++ SDK,本块未经编译验证,仅作对照。
// 引擎同样只能通过 readNet 的第 4 个参数传入,Net 上没有 setEngine(3.1 实测)。
inline cv::dnn::Net pickEngine(const std::string& model) {
return cv::dnn::readNet(model, "", "",
cv::dnn::DNN_ENGINE_ORT); // 第 4 个参数
}
// 4.x 的写法在 5.0.0 编译不过:Net 没有 setEngine 方法
// net.setEngine(cv::dnn::DNN_ENGINE_ORT);
为什么这段对 C++ 迁移特别关键 :Python 里位置参数错一位得到的是一个莫名其妙的格式报错,C++ 里因为有重载,报错形式可能完全不同 ------甚至可能匹配到另一个 readNet 重载而顺利编译出一个"能跑但用错引擎"的对象。所以 C++ 侧更应该显式写出全部四个参数,像上面这样,而不是依赖默认参数或前三个参数的省略。
3.2 setPreferableTarget 在 5.0.0 上是空操作
这是本章第一个反直觉的实测。先看 API 还在:
python
import cv2
dnn = cv2.dnn
net = dnn.readNet(CLS)
net.setPreferableBackend(dnn.DNN_BACKEND_OPENCV)
net.setPreferableTarget(dnn.DNN_TARGET_OPENCL_FP16) # 调用成功,不报错
print("调用完成")
调用成功返回,不抛异常。但 stderr 每次都会打一行:
text
[ WARN:0@0.041] global net_impl_backend.cpp:345
cv::dnn::dnn5_v20260605::Net::Impl::setPreferableTarget
Targets are not supported by the new graph engine for now
翻译过来就是:5.0.0 的新引擎不支持 target 选择,你这个调用被忽略了。
(想确认这条警告确实来自 setPreferableTarget 而不是别的调用,把这一行注释掉重跑,警告消失即可。我就是这么定位的。)
为什么这件事必须写清楚 :网上大量教程告诉你"setPreferableTarget(DNN_TARGET_OPENCL) 能让推理快好几倍"。在 5.0.0 的默认引擎下,这个调用是 no-op。4.2/4.5 时代它是真的有效,5.0.0 换了引擎实现之后失效了,但签名和返回值都没变------所以它变成了一个"静默无效"的 API。
它甚至没有走 cv2.dnn.setLogLevel 那套 trace 常量,而是直接打到 C++ 的 stderr。这意味着:你把它包在 try/except 里是抓不到的,因为根本没抛异常。
这行 WARN 还会把你的输出刷屏。实测在一次 13 模型 × 4 引擎的循环里,它出现了 6 次。静音办法是 OpenCV 全局日志开关:
python
import cv2
cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_SILENT)
可用级别:LOG_LEVEL_SILENT / LOG_LEVEL_FATAL / LOG_LEVEL_ERROR / LOG_LEVEL_WARNING / LOG_LEVEL_INFO / LOG_LEVEL_VERBOSE / LOG_LEVEL_DEBUG。
注意这是 cv2.utils.logging,不是 cv2.dnn.setLogLevel (后者在 5.0.0 已经不存在,见 1.4)。我把 getLogLevel() 打出来确认过:静音后返回 1,也就是 LOG_LEVEL_FATAL。
3.3 52 格矩阵:能跑的只有 2 个模型
现在把手上所有检测模型 × 所有引擎全跑一遍。输入统一用 1×3×640×640 的随机张量:
python
import cv2, numpy as np, glob, os
cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_FATAL)
dnn = cv2.dnn
blob = np.ascontiguousarray(
np.random.default_rng(0).random((1, 3, 640, 640), dtype=np.float32))
models = sorted(glob.glob(DET_GLOB)) + sorted(glob.glob(DET_GLOB_OLD))
engines = ["ENGINE_AUTO", "ENGINE_CLASSIC", "ENGINE_NEW", "ENGINE_ORT"]
working = []
for m in models:
for en in engines:
try:
n = dnn.readNet(m, "", "", getattr(dnn, en))
except Exception as e:
print("%-32s %-14s 读网失败" % (os.path.basename(m)[:32], en)); continue
try:
n.setInput(blob); o = n.forward()
print("%-32s %-14s 可跑 %s" % (os.path.basename(m)[:32], en, o.shape))
if en == "ENGINE_AUTO": working.append((m, o.shape))
except Exception as e:
print("%-32s %-14s 前向失败 %s"
% (os.path.basename(m)[:32], en, str(e).split("error:")[-1][:70]))
print("可跑模型数(ENGINE_AUTO):", len(working))
实测 13 个模型 × 4 个引擎。能在 AUTO 下跑起来的只有 2 个:
text
train-yolo8s-obb.onnx ENGINE_AUTO 可跑 (4, 10, 2100)
train-yolo8s-obb.onnx ENGINE_CLASSIC 可跑 (4, 10, 2100)
train-yolo8s-obb.onnx ENGINE_NEW 可跑 (4, 10, 2100)
train-yolo8s-obb.onnx ENGINE_ORT 可跑 (4, 10, 2100)
train-yolo8s-obb_fp32.onnx ENGINE_AUTO 可跑 (4, 10, 2100)
train-yolo8s-obb_fp32.onnx ENGINE_CLASSIC 可跑 (4, 10, 2100)
train-yolo8s-obb_fp32.onnx ENGINE_NEW 可跑 (4, 10, 2100)
train-yolo8s-obb_fp32.onnx ENGINE_ORT 可跑 (4, 10, 2100)
可跑模型数(ENGINE_AUTO): 2
失败的三类,报错信息完全不同:
| 失败类型 | 报错 | 出现在 |
|---|---|---|
| 残差形状不匹配 | (-5:Bad argument) residual added after convolution must have the same shape and the same type |
YOLOv11 / v12 在 AUTO / NEW / ORT |
| Concat 形状不一致 | (-201:Incorrect size of input array) Inconsistent shape for ConcatLayer |
同上,但只在 CLASSIC |
| 量化层建不出来 | (-2:Unspecified error) Can't create layer "onnx_node!images_QuantizeL..." |
全部 _cpu_int8 变体 |
第一类:YOLOv11/v12 的 OBB 模型在 5.0.0 上完全跑不了。 不是某一个不行,是 single-panel-trial、yolo11s-obb(含 485、data6 两个变体)、yolo12s-obb 及其 fp32 版本全军覆没。
同一份模型在 CLASSIC 上的报错是 Inconsistent shape for ConcatLayer,说明两个引擎都支持不了,只是失败的位置不同。你换引擎救不回来。
OpenCV 给的第一类错误里有一句很实在的提示:唯一解法是改模型、给残差张量补 Expand / Cast。这句话反过来告诉你:这是 ONNX 图到 OpenCV 层实现的映射缺口,不是你的代码错了。
第二类:_cpu_int8 变体在 readNet 阶段就挂。
text
train-yolo11s-obb_cpu_int8.onnx ENGINE_AUTO 读网失败 (-2) Can't create layer "onnx_node!images_QuantizeL
train-yolo11s-obb_cpu_int8.onnx ENGINE_CLASSIC 读网失败 (-2) Can't create layer "onnx_node!images_QuantizeL
train-yolo11s-obb_cpu_int8.onnx ENGINE_ORT 读网失败 (-2) Can't create layer "onnx_node!images_QuantizeL
train-yolo11s-obb_cpu_int8.onnx ENGINE_NEW 前向失败 (-215:Assertion failed) !empty()
QuantizeLinear / DequantizeLinear 这对量化节点 5.0.0 的 ONNX 导入器认不出来 。注意 _cpu_int8 这个文件名前缀已经明确告诉你它是为了 CPU 量化准备的------而本机 build 信息里 DNN MLAS: NO(1.7 节)。
这两条合起来是一个结论 :想走 int8 加速,5.0.0 的 dnn 模块这条路在本机是断的。可行替代是 ONNX Runtime(ENGINE_ORT 存在,但模型本身先得能被 ORT 引擎读到------这里失败发生在 readNet,还没轮到 ORT)。
3.4 分类模型端到端:blob → forward
用能跑的那个分类模型走完整链路:
python
import cv2, numpy as np
cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_FATAL)
dnn = cv2.dnn
IMG = np.zeros((640, 640, 3), np.uint8)
IMG[:, :] = (40, 40, 40)
cv2.rectangle(IMG, (120, 120), (320, 320), (30, 200, 240), -1)
cv2.circle(IMG, (450, 450), 90, (220, 60, 40), -1)
cv2.line(IMG, (0, 0), (639, 639), (255, 255, 255), 5)
rng = np.random.default_rng(12345)
for _ in range(400):
x, y = int(rng.integers(0, 640)), int(rng.integers(0, 640))
cv2.rectangle(IMG, (x, y), (x + 14, y + 14),
(int(rng.integers(0, 255)),) * 3, -1)
blob = dnn.blobFromImage(IMG, scalefactor=1.0 / 255.0, size=(224, 224),
swapRB=True, crop=False, ddepth=cv2.CV_32F)
print("blob:", blob.shape, blob.dtype,
"范围 %.4f~%.4f 均值 %.4f" % (blob.min(), blob.max(), blob.mean()))
net = dnn.readNet(CLS)
print("输入层:", net.getLayerNames()[0])
print("输出层:", net.getUnconnectedOutLayersNames())
print("层数:", len(net.getLayerNames()))
net.setInput(blob)
out = net.forward()
print("输出:", out.shape, out.dtype)
text
blob: (1, 3, 224, 224) float32 范围 0.0000~1.0000 均值 0.2780
输入层: trlayout.0
输出层: ('output0',)
层数: 95
输出: (1, 7) float32
输入层名 trlayout.0 里的 tr 就是 transpose------Ultralytics 导出时把 NHWC→NCHW 的 permute 做成了一个真实图层,不是权重层面的融合。这意味着每次前向都要付一次 transpose 的钱,而它恰好也是 profile 里最贵的单层(4.4 节)。
7 类输出:
python
o = out.ravel()
print("求和:", float(o.sum()))
print("argmax:", int(o.argmax()))
print("全部概率:", ["%.6f" % float(v) for v in o])
text
求和: 1.000000
argmax: 3
全部概率: ['0.000000', '0.000000', '0.000000', '1.000000', '0.000000', '0.000000', '0.000000']
求和恰好 1.000000,说明这个导出已经带了 softmax 。但注意这是完全饱和的 one-hot:6 个类严格是 0,1 个类严格是 1.0。
这不是模型很准,这是输入太离谱。 我喂的是一张纯合成图:深灰底 + 一个矩形 + 一个圆 + 一条白斜线 + 400 个随机小方块。分类模型对这种输入给出压倒性的单一类别,典型是分布外输入下的过置信。
为什么这个饱和现象本身值得写 :softmax 输出的置信度不能直接当作"模型有多确定"的证据 。一个分布外输入会得到 1.0000 的"满分置信",而它的实际含义是"我完全不认识这张图"。真正可靠的置信度需要配合分布内验证集、或者用温度缩放 / 类温度校准来修正。这一条对第六章的分工判断很重要:DNN 给你置信度,但这个置信度需要你自己去验证它可不可信。
3.5 检测模型的输入尺寸是死的
用能跑的 train-yolo8s-obb.onnx 扫一遍输入尺寸:
python
import cv2, numpy as np
dnn = cv2.dnn
good = None
for sz in (320, 416, 512, 640, 800, 1024):
b = dnn.blobFromImage(IMG, scalefactor=1.0 / 255.0, size=(sz, sz),
swapRB=True, crop=False, ddepth=cv2.CV_32F)
n = dnn.readNet(dm, "", "", dnn.ENGINE_AUTO)
n.setInput(b)
try:
o = n.forward()
if good is None: good = sz
print("size=%4d 可跑 输出 %s 总候选=%d" % (sz, o.shape, np.prod(o.shape[1:])))
except Exception as e:
print("size=%4d 失败 %s" % (sz, str(e).split("error:")[-1][:80]))
text
size= 320 可跑 输出 (1, 10, 2100) 总候选=21000
size= 416 失败 (-215:Assertion failed) autoSize <= INT_MAX && autoSize*outTotal == inpTotal
size= 512 失败 (-215:Assertion failed) autoSize <= INT_MAX && autoSize*outTotal == inpTotal
size= 640 可跑 输出 (4, 10, 2100) 总候选=21000
size= 800 失败 (-215:Assertion failed) autoSize <= INT_MAX && autoSize*outTotal == inpTotal
size=1024 失败 (-215:Assertion failed) autoSize <= INT_MAX && autoSize*outTotal == inpTotal
三个观察:
- 候选数恒为 2100,跟输入尺寸无关 。320 输入理论上是
(320/8)² + (320/16)² + (320/32)² = 40² + 20² + 10² = 1600 + 400 + 100 = 2100,正好。640 输入本该是80² + 40² + 20² = 6400 + 1600 + 400 = 8400,实际还是 2100。 - 失败的都是
reshape2_layer.cpp的autoSize*outTotal == inpTotal断言------reshape 的元素总数对不上。 - 320 和 640 能过,其他都挂。
结论:导出时把 imgsz 写死了,模型内部按固定 stride 组合 reshape。你喂 640 它不会报错,但几何全错(元素总数对不上,模型只是没在 reshape 那一层卡住)。
为什么这个坑很隐蔽 :640 是 YOLO 训练时最常用的 imgsz,一个"看起来完全合理"的输入。结果模型前向成功、输出 2100 个候选、框坐标离谱(下一节),你要是只看"有没有报错"就会以为一切正常。
工程做法:输入尺寸从模型自身读,不要猜。 有两条路------要么记录导出时的 imgsz,要么先跑一次 forward 看输出元素总数能不能和理论 anchor 数对上(本文后面就用这个自检)。
3.6 输出布局:reshape 的轴选错了就全盘皆错
输出 (1, 10, 2100)。第一维是 1(batch),通道是第二维 10,第三维 2100 才是候选数。
python
a = dout.reshape(-1, dout.shape[1]) # 正确:(2100, 10)
print(a.shape)
text
(2100, 10)
我第一次写成了 reshape(-1, dout.shape[-1]),得到 (10, 2100),然后按"每行一个候选"去解读,读出"通道数 2100、类别数 2095"这种荒谬结论。同一份数据,reshape 的轴选错,后面全错,而且不会报错。
布局自检(这一步能把轴选错当场抓住):
python
SZ = 320
exp = sum((SZ // s) ** 2 for s in (8, 16, 32))
print("理论候选数 (S/8)^2+(S/16)^2+(S/32)^2 =", exp)
print("实际候选数 =", a.shape[0])
print("布局自洽:", exp == a.shape[0])
text
理论候选数 (S/8)^2+(S/16)^2+(S/32)^2 = 2100
实际候选数 = 2100
布局自洽: True
2095 个类别这种荒谬数字其实是个好事------它响得足够大。如果你读到"类别数上千",立刻就知道自己轴选错了。如果布局选错但恰好数字看起来合理,那就真的查不出来了。
3.7 这个导出吐的是原始 logit,不是概率
拿到 (2100, 10) 之后逐通道统计:
python
for i in range(10):
col = a[:, i]
print("ch%-2d min=%9.3f max=%9.3f 均值=%8.3f 落在[0,1]=%s"
% (i, col.min(), col.max(), col.mean(),
"是" if (col.min() >= 0 and col.max() <= 1.001) else "否"))
text
ch0 min= -0.012 max= 316.442 均值= 32.263 落在[0,1]=否
ch1 min= -0.011 max= 316.188 均值= 33.590 落在[0,1]=否
ch2 min= -0.012 max= 316.366 均值= 34.425 落在[0,1]=否
ch3 min= -0.008 max= 316.476 均值= 35.387 落在[0,1]=否
ch4 min= -0.011 max= 316.330 均值= 36.354 落在[0,1]=否
ch5 min= -0.010 max= 315.893 均值= 37.360 落在[0,1]=否
ch6 min= -0.019 max= 315.552 均值= 38.374 落在[0,1]=否
ch7 min= -0.021 max= 316.126 均值= 39.434 落在[0,1]=否
ch8 min= -0.011 max= 316.418 均值= 40.545 落在[0,1]=否
ch9 min= -0.013 max= 316.449 均值= 41.291 落在[0,1]=否
没有一个通道落在 0,1 。前 5 个通道按 OBB 约定是 cx, cy, w, h, angle,后 5 个是 5 个类别------类别分数也是原始 logit。
先确认自己没看错列:sigmoid 之后范围应该落进 0,1。
python
logits = a[:, 5:]
sig = 1.0 / (1.0 + np.exp(-logits))
print("过 sigmoid 后范围: %.6f ~ %.6f" % (sig.min(), sig.max()))
print("落在 [0,1]:", bool(sig.min() >= 0 and sig.max() <= 1.0))
text
过 sigmoid 后范围: 0.494730 ~ 1.000000
落在 [0,1]: True
漏掉 sigmoid 的后果可以直接算出来:
python
best_raw = logits.max(axis=1)
best = sig.max(axis=1)
print("最大 logit: %.4f" % best_raw.max())
print("最大 sigmoid 概率: %.6f" % best.max())
print("漏掉 sigmoid,logit>=0.25 的候选数:", int((best_raw >= 0.25).sum()), "/", len(a))
for thr in (0.10, 0.25, 0.50):
print("sigmoid 概率>=%.2f 候选数: %d" % (thr, int((best >= thr).sum())))
text
最大 logit: 316.4491
最大 sigmoid 概率: 1.000000
漏掉 sigmoid,logit>=0.25 的候选数: 959 / 2100
sigmoid 概率>=0.10 候选数: 2100
sigmoid 概率>=0.25 候选数: 2100
sigmoid 概率>=0.50 候选数: 2097
959 / 2100------阈值形同虚设,几乎所有候选都通过了。
而且注意最后一组数字:即使加对了 sigmoid,2100 个候选里有 2097 个仍然 ≥ 0.50。 这是 3.8 节要处理的饱和问题,但方向完全不同:前一个 bug 是你的阈值失效 ,后一个现象是模型输出本身饱和。
3.8 同一个工程,两种导出口径
把 3.4 和 3.7 放在一起看:
| 模型 | 输出范围 | 求和 | 需要自己 sigmoid? |
|---|---|---|---|
defect_cls_*_yolo11n_* |
0.000000, 1.000000 | 恰好 1.000000 | 否,已带 softmax |
train-yolo8s-obb.onnx |
-0.0211, 316.4765 | --- | 是,原始 logit |
同一套 Ultralytics 导出流程,同一个工程目录,两个模型的输出口径不一致。
为什么这件事必须逐个模型确认:分类模型让你养成了"输出就是概率"的直觉,然后把这个直觉带到检测模型上,就掉进 3.7 那个坑。反过来,如果你先写的是检测代码,看到 316 会立刻意识到要 sigmoid。
判断方法一行就够:
python
def needs_sigmoid(out):
"""输出里只要有超出 [0,1] 的值,就是 logit。"""
return bool(out.min() < 0.0 or out.max() > 1.001)
工程做法 :把这一行塞进你的推理封装,对每个模型在接入时验一次,比读文档可靠------因为文档通常描述的是一种 导出设置,而实际拿到的是某个训练脚本特定的设置。
3.9 后处理跑通了,但输出是垃圾 ------ 以及我为什么不写精度数字
带着 sigmoid 继续走完 NMS:
python
thr = 0.25
keep = best >= thr
b = a[keep]
cx, cy, w, h, ang = (b[:, i] for i in range(5))
SZ = 320
boxes = np.stack([(cx - w / 2) * SZ, (cy - h / 2) * SZ,
w * SZ, h * SZ], axis=1).astype(np.float32)
sc = best[keep].astype(np.float32)
print("送入 NMS 的框数:", len(boxes))
print("框 x 范围: %.1f ~ %.1f" % (boxes[:, 0].min(), boxes[:, 0].max()))
print("框 w 范围: %.1f ~ %.1f" % (boxes[:, 2].min(), boxes[:, 2].max()))
idx = dnn.NMSBoxes(boxes, sc, float(thr), 0.45)
print("NMSBoxes(0.25, 0.45) 保留数:", len(idx))
text
送入 NMS 的框数: 2100
框 x 范围: -6032.1 ~ 50822.6
框 y 范围: -3106.1 ~ 50868.9
框 w 范围: -3.7 ~ 101237.1
框 h 范围: -2.6 ~ 101272.5
NMSBoxes(0.25, 0.45) 保留数: 715
一张 320×320 的图,框宽最大 101237 像素。 这是彻底的垃圾输出。
诊断一下饱和的严重程度------回看 3.7 的逐通道统计:10 个通道的最大值全部落在 315.5~316.5 这个极窄区间,而均值从 ch0 的 32.3 单调递增到 ch9 的 41.3。一个正常的检测头输出,10 个通道的量级不该这么整齐地挤在一起。
再对照 3.4:同一份合成图,分类模型给的是严格 one-hot(6 个类精确为 0.000000)。两个模型在分布外输入上都表现出"输出钉死在边界"的特征。
所以我能诚实地说什么:
- ✅ 能说:blob → forward → sigmoid → NMS 这条链路是通的,NMSBoxes 在真实模型输出上正常工作。
- ✅ 能说:这些模型在 5.0.0 上能不能跑,答案由 3.3 的矩阵给出。
- ❌ 不能说:这个检测模型的精度、召回率、mAP 是多少。
因为我没有真实测试图。 3.3 找到的 46 个模型是我自己工程目录下的资产,但我手上没有配套的标注数据、也没有分布内的验证集。用一张合成图去测检测质量,得到的只有"链路没崩"这一个结论。
这就是这一章最想传达的东西 :前向成功 ≠ 模型可用 。forward() 返回一个形状正确的数组、NMSBoxes 返回一个非空索引列表------这两件事都不构成任何关于模型质量的证据。要证明一个检测模型能用,你至少需要:分布内的真实样本、标注、以及据此算出的指标。
这一章其余所有数字都只回答"能不能跑"和"跑多快",不回答"准不准"。所以这条纪律得靠你在自己的项目里执行:接模型之前先准备分布内的真实样本和标注,再谈指标。
四、推理性能:backend/target 无效时,从哪里抠时间
3.2 已经说明 setPreferableTarget 在 5.0.0 上是 no-op。那"让它更快"这件事就只能从别的地方入手。
4.1 计时的第一步:warmup
这是本章最该先讲的一条,因为它决定了你后面所有数字有没有意义。
getPerfProfile() 报告的首次前向总时间是 5254.80 ms 。而同一个模型在 warmup 之后的稳态前向是 4.6498 ms:
text
prof[0](总时间) int = 5254.80 ms
[5] 稳态前向 ENGINE_AUTO 4.6498 ms/次
差 1130 倍。
原因不是"第一次慢一点",是新引擎在第一次前向里做了图优化和 shape 推导------这类一次性开销。如果你拿首次前向的时间去做性能预算,会得出"这个模型要 5 秒一帧"的结论,然后错误地判定它不能上产线。
所以任何计时都必须先 warmup:
python
import time
def bench(fn, n=20, warm=3):
for _ in range(warm):
fn() # 丢弃 warmup 轮
t0 = time.perf_counter()
for _ in range(n):
fn()
return (time.perf_counter() - t0) * 1000.0 / n
net = dnn.readNet(CLS)
net.setInput(blob)
print("含 warmup 语义: %.4f ms/次" % bench(lambda: net.forward()))
OpenCV 自带 TickMeter,跨语言口径一致(第十一篇已经推荐过):
python
tm = cv2.TickMeter()
tm.start()
for _ in range(20):
net.forward()
tm.stop()
print("TickMeter 平均: %.4f ms" % tm.getAvgTime())
4.2 四种引擎的真实耗时
同一份数据、同一套预处理,只换引擎参数:
python
ref = None
for en in ("ENGINE_AUTO", "ENGINE_CLASSIC", "ENGINE_NEW", "ENGINE_ORT"):
n = dnn.readNet(CLS, "", "", getattr(dnn, en))
n.setInput(blob)
t = bench(lambda: n.forward(), n=20)
o = n.forward().ravel()
if ref is None:
ref = o.copy()
diff = float(np.max(np.abs(o - ref)))
print("%-16s %.4f ms/次 max|Δ vs AUTO|=%.3e" % (en, t, diff))
text
ENGINE_AUTO 4.6498 ms/次 max|Δ vs AUTO|=0.000e+00
ENGINE_CLASSIC 10.9222 ms/次 max|Δ vs AUTO|=0.000e+00
ENGINE_NEW 4.6149 ms/次 max|Δ vs AUTO|=0.000e+00
ENGINE_ORT 4.3837 ms/次 max|Δ vs AUTO|=0.000e+00
相对 AUTO ENGINE_AUTO 1.000x
相对 AUTO ENGINE_CLASSIC 0.426x
相对 AUTO ENGINE_NEW 1.008x
相对 AUTO ENGINE_ORT 1.061x
三个结论:
1. CLASSIC 明显慢。 4.65 → 10.92 ms,慢 2.35 倍。迁移到 5.0.0 时不要把引擎留在 CLASSIC------如果你为了兼容选了它,代价是实打实的 2.3 倍。
2. NEW / ORT / AUTO 三者基本持平。 ORT 只快 6.1%,NEW 快 0.8%。在 CPU 上,ORT 后端没有表现出"换了运行时就有明显优势"。
3. 四种引擎输出逐位相同(max|Δ| = 0.000e+00)。
第三点值得单独说:数值完全一致 。这不是"四舍五入后相同",是 max|Δ 严格为 0。这意味着在 CPU 上换引擎不改变任何输出值,所以选引擎纯粹是性能决策,不影响精度。
为什么这一点重要 :它给了一个免费的验证手段。如果你在换引擎后看到输出变化了一个小量,别怀疑精度损失------那更可能意味着某个引擎走了不同的计算路径(比如真的降到了低精度)。本机 DNN MLAS: NO(1.7 节),低精度路径没有硬件加速基础,所以四个引擎结果相同是自洽的。
但这个结论不能外推 :它只在本机、CPU、这个模型上成立。DNN_TARGET_CUDA / OPENCL 是另一回事,而它们在 5.0.0 上压根选不了(3.2)。
4.3 getPerfProfile 的返回结构变了
4.x 里 getPerfProfile() 返回一个 4-tuple 的列表:(层名, 微秒数, 标识, 线程 id)。5.0.0 不是:
python
prof = net.getPerfProfile()
print("返回类型:", type(prof), "长度:", len(prof))
print("prof[0]:", type(prof[0]).__name__, "=", float(prof[0]) / 1000.0, "ms")
times = np.asarray(prof[1], dtype=np.float64).ravel()
print("prof[1]:", type(prof[1]).__name__, "/", times.size, "项")
names = net.getLayerNames()
print("getLayerNames 长度:", len(names))
print("两者长度是否相等:", times.size == len(names))
text
返回类型: <class 'tuple'> 长度: 2
prof[0]: int = 5254.80 ms
prof[1]: ndarray / 95 项
getLayerNames 长度: 95
两者长度是否相等: True
5.0.0 返回一个 2-tuple:(总时间, 每层时间数组)。
我第一次按 4.x 的写法去解包,直接炸了:
text
TypeError: 'int' object is not subscriptable
因为 prof[0] 是个 int,int 不能下标。而 4.x 的解包循环 for lay, us, tag, tid in prof: 会尝试把 prof[0](一个 int)解成 4 个变量------4.x 里 prof 是列表不是元组,所以不会走到这个坑。
5.0.0 丢掉了三项信息 :层名、层类型、线程 id。prof[1] 是一个纯浮点数组 ,没有任何标签。你只能按位置和 getLayerNames() 对齐:
python
order = np.argsort(-times)[:10]
for i in order:
nm = names[i] if i < len(names) else "<越界:%d>" % i
print(" %-30s %9.2f us" % (str(nm)[:30], times[i]))
实测最贵的 10 层:
| 层名 | 微秒 |
|---|---|
/model.5/conv/Conv |
486000.00 |
trlayout.0 |
482200.00 |
/model.10/conv/conv/Conv |
356400.00 |
/model.0/conv/Conv |
355300.00 |
/model.3/conv/Conv |
334600.00 |
/model.2/cv2/conv/Conv |
320100.00 |
/model.1/conv/Conv |
202200.00 |
/model.7/conv/Conv |
196100.00 |
/model.2/Concat |
182100.00 |
/model.4/cv2/conv/Conv |
122200.00 |
text
数组求和 5254800.00 us
与 prof[0] 的比值 1.0000
前 5 层占比 38.3%
两个必须自己加的校验:
python
print("数组求和 / prof[0] = %.4f" % (times.sum() / float(prof[0])))
print("前 5 层占比 = %.1f%%" % (100.0 * times[np.argsort(-times)[:5]].sum() / times.sum()))
sum(prof[1]) / prof[0] == 1.0000 是一个很便宜的自检------如果这俩对不上,说明你拿到的时间和层时间不是同一次前向的(比如中间又跑了一次 forward),或者 prof 数组被覆盖了。
C++ 侧对照:
cpp
// 未编译:本机没有 OpenCV 5.0.0 C++ SDK,本块未经编译验证,仅作对照。
// 5.0.0 的 getPerfProfile 返回 2-tuple(Python 实测见 4.3);
// 4.x 那种「每项含 (层名, 微秒, 类型, 线程id)」的结构已经没有了。
#include <cstdio>
#include <string>
#include <vector>
void dumpProfile(cv::dnn::Net& net) {
const auto prof = net.getPerfProfile(); // 5.0.0: (int, ndarray)
const std::vector<cv::Mat> perLayer = prof[1];
const std::vector<std::string> names = net.getLayerNames();
// 自检 1:层时间数必须与层名数相等,否则不能按位置对齐(4.3 实测均为 95)
// CV_Assert(perLayer.size() == names.size());
// 自检 2:各层时间求和 / 总时间 应为 1.0(4.3 实测 1.0000)
// CV_Assert(std::abs(total / double(prof[0]) - 1.0) < 1e-6);
for (std::size_t i = 0; i < perLayer.size(); ++i) {
printf(" %-30s %9.2f us\n", names[i].c_str(), perLayer[i].at<double>(0));
}
}
为什么 C++ 侧更要注意这一步 :4.x 的迁移代码通常遍历每个 profile item、再用 item 里的名字字段直接输出。5.0.0 里那份 item 不再带层名,你只能靠下标 i 去 getLayerNames()[i] 配对 。上面两个 CV_Assert 我注释掉了,但建议在项目里真的打开------层数与名字数不一致时,按位置配对会读到越界的名字,而这种越界在循环里往往不立刻崩,可能一路带着错误的层名输出到日志里。
表里第二名 trlayout.0 占 482200 µs,值得单独说 。这就是 3.4 节提到的 NHWC→NCHW permute 层,它占了总时间的 9.2%。
为什么它是个优化点 :这个 transpose 是纯内存重排,不做任何算术。如果你能把 NCHW 输入直接喂给它,就能省掉 9.2%。 做法是自己写 img[:, :, ::-1].transpose(2, 0, 1) 生成 NCHW 数组,避开 blobFromImage:
python
def blob_nchw_hwc(img, size, scale=1.0 / 255.0, mean=None):
"""手动构造 NCHW,跳过 blobFromImage 的 permute 图层。"""
if img.shape[:2] != (size[1], size[0]):
img = cv2.resize(img, size, interpolation=cv2.INTER_LINEAR)
x = img.astype(np.float32)
if mean is not None:
x = x - np.asarray(mean, np.float32)
x *= scale
return np.ascontiguousarray(x.transpose(2, 0, 1)[None])
注意这个优化有前提 :它只对"输入图布局与网络输入布局不一致、且模型里有一层显式 permute"的情况有效。如果模型的 permute 已经在导出时被权重吸收(有些流程会做 simplify),trlayout.0 这层就不存在,你手写 transpose 反而多了一次拷贝。判断依据是 profile 里有没有它,不是想当然。
4.4 读网比推理贵 4.4 倍
这一节是我认为对工程最有价值的一个数字。把三段拆开测:
python
t3 = bench(lambda: dnn.blobFromImage(IMG, scalefactor=1.0 / 255.0,
size=(320, 320), swapRB=True,
crop=False, ddepth=cv2.CV_32F), n=20)
tr = bench(lambda: dnn.readNet(dm, "", "", dnn.ENGINE_AUTO), n=3, warm=1)
nb = dnn.readNet(dm, "", "", dnn.ENGINE_AUTO)
nb.setInput(dblob)
t2 = bench(lambda: nb.forward(), n=20)
print("blobFromImage %.4f ms/次" % t3)
print("readNet %.2f ms/次" % tr)
print("forward %.4f ms/次" % t2)
print("推理复用后端到端 %.2f ms" % (t3 + t2))
print("首次端到端 %.2f ms" % (tr + t3 + t2))
print("读网是单次前向的 %.1f 倍" % (tr / t2))
text
blobFromImage 1.4180 ms/次
readNet 184.25 ms/次
forward 41.7502 ms/次
推理复用后端到端 43.17 ms
首次端到端 227.42 ms
读网是单次前向的 4.4 倍
(检测模型 41.75 ms,分类模型 4.65 ms------2.3 倍前向差距。readNet 慢是因为检测模型 45 MB、分类模型小得多。)
为什么这个 4.4 倍是重点 :readNet 184.25 ms vs forward 41.75 ms。
后果一 :如果你在循环里 readNet,每帧多花 184 ms,总耗时是 227 ms 而不是 43 ms,慢 5.3 倍 。这是新手代码里极常见的一种写法,因为示例代码为了简洁总是把 readNet 放在函数里:
python
# ❌ 错的:每帧重读模型
def detect(img):
net = dnn.readNet("model.onnx") # 184.25 ms,每帧都付
net.setInput(blob)
return net.forward()
# ✅ 对的:读一次,循环外复用
_net = None
def detect(img, net=None):
global _net
if net is None:
net = _net = dnn.readNet("model.onnx")
net.setInput(blob)
return net.forward()
后果二 :setInput 是可以重复调用的,它只更新绑定的 blob,不会重置网络状态 。所以复用 net 是安全的,Net 不是一次性对象。这一点在 C++ 侧也是一样的语义。
工程纪律 :readNet 属于启动期成本,写在 __init__ 或模块加载处;forward 属于运行期成本,写在循环里。这两类开销差 4.4 倍,混在一起会让你的性能预算彻底失真。
4.5 传统算子的单步耗时
对照组,同一张 640×640 图,18 线程默认配置:
python
g = cv2.cvtColor(IMG, cv2.COLOR_BGR2GRAY)
for lbl, fn in [
("cvtColor", lambda: cv2.cvtColor(IMG, cv2.COLOR_BGR2GRAY)),
("OTSU 阈值", lambda: cv2.threshold(g, 0, 255,
cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]),
("Canny 边缘", lambda: cv2.Canny(g, 100, 200)),
("findContours", lambda: cv2.findContours(g, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)),
("resize 640", lambda: cv2.resize(IMG, (640, 640))),
]:
print("%-16s %.4f ms/次" % (lbl, bench(fn, n=20)))
text
cvtColor 0.1176 ms/次
OTSU 阈值 0.4216 ms/次
Canny 边缘 0.4683 ms/次
findContours 0.1957 ms/次
resize 640 0.7458 ms/次
和 4.2 的 4.6498 ms(分类前向)、4.4 的 41.75 ms(检测前向)放在一起:
| 操作 | 耗时 | 相对 Canny |
|---|---|---|
cvtColor |
0.1176 ms | 0.25x |
findContours |
0.1957 ms | 0.42x |
| OTSU 阈值 | 0.4216 ms | 0.90x |
Canny |
0.4683 ms | 1.00x |
resize 640 |
0.7458 ms | 1.59x |
| 分类模型前向 | 4.6498 ms | 9.9x |
| 检测模型前向 | 41.7502 ms | 89.2x |
readNet(一次性) |
184.25 ms | 393x |
这张表是第六章分工判断的全部依据。 单个传统算子在 0.1~0.7 ms,一次分类前向是它的 10 倍,一次检测前向是它的 89 倍。
但------这不是"DNN 更慢所以别用"的结论。传统算子要做成完整检测流水线需要十几个步骤串起来,而且给不出类别和置信度。第六章会把这件事量化。
五、NMS 家族:四个函数,四套脾气
模型输出到最终检测框,中间必须过非极大值抑制。cv2.dnn 里这一族有四个函数:
text
NMSBoxes(bboxes, scores, score_threshold, nms_threshold[, eta[, top_k]]) -> indices
NMSBoxesBatched(bboxes, scores, class_ids, score_threshold, nms_threshold[, eta[, top_k]]) -> indices
NMSBoxesRotated(bboxes, scores, score_threshold, nms_threshold[, eta[, top_k]]) -> indices
softNMSBoxes(bboxes, scores, score_threshold, nms_threshold[, top_k[, sigma[, method]]]) -> updated_scores, indices
只有 softNMSBoxes 返回两个值(更新后的分数 + 索引),另外三个只返回索引。
这一章的每个结论都标注了 probe 文件编号,因为我在这个模块上推翻了自己两次(5.7、5.9)。
5.1 框格式是 xywh,不是 xyxy
先钉死最基础的一件事。用两个"只共享中心附近区域"的框验证:
python
import cv2, numpy as np
dnn = cv2.dnn
B = np.array([[0., 0., 100., 10.], # 宽 100 高 10(横向)
[0., 0., 10., 100.]], np.float32) # 宽 10 高 100(纵向)
S = np.array([0.9, 0.8], np.float32)
print("nms=0.03:", dnn.NMSBoxes(B, S, 0.0, 0.03))
print("nms=0.06:", dnn.NMSBoxes(B, S, 0.0, 0.06))
text
nms=0.03: [0]
nms=0.06: [0, 1]
这两个框在 xyxy 解释下完全重合 (都是 [0,0,100,100]),任何 IoU 阈值都会合并它们。实测它们在 nms=0.06 时被保留为两个------证明输入是 xywh :(0,0,100,10) 和 (0,0,10,100) 只共享 [0,0,10,10] 这一小块,IoU 约 0.0526,刚好卡在 0.03 和 0.06 之间。
所以 NMSBoxes 的第 3、4 列是宽和高,不是右下角坐标。 而 cv2.dnn.NMSBoxesRotated(C++ 侧)用的是 5 点格式------两者不是一回事,别混。
5.2 score_threshold 是严格大于(开区间)
这条值得单独查,因为很多文档写的是模糊的"低于阈值丢弃":
python
B = np.array([[10., 10., 100., 100.], [15., 15., 100., 100.],
[300., 300., 80., 80.], [12., 12., 100., 100.]], np.float32)
S = np.array([0.9, 0.85, 0.7, 0.88], np.float32)
for t in (0.0, 0.5, 0.7, 0.8, 0.86, 0.88, 0.8999, 0.9, 0.9001, 0.95, 0.99):
print("score_threshold=%.4f -> %s" % (t, dnn.NMSBoxes(B, S, t, 0.5)))
text
score_threshold=0.0000 -> [0, 2]
score_threshold=0.5000 -> [0, 2]
score_threshold=0.7000 -> [0]
score_threshold=0.8000 -> [0]
score_threshold=0.8600 -> [0]
score_threshold=0.8800 -> [0]
score_threshold=0.8999 -> [0]
score_threshold=0.9000 -> []
score_threshold=0.9001 -> []
score_threshold=0.9500 -> []
score_threshold=0.9900 -> []
看 0.7000 和 0.9000 这两行:
- 分数 0.7 的框(索引 2)在
threshold=0.7000时被丢弃 → 判定是score > threshold; - 分数 0.9 的框(索引 0)在
threshold=0.8999时保留,在0.9000时被丢弃 → 印证score > threshold,恰好相等即淘汰。
如果分数恰好等于阈值,这个框会被丢掉。 从 0.8999 保留到 0.9000 丢弃的那一行跳变,就是这个语义的直接证据。
顺带注意 0.7000 那行从 [0, 2] 变成 [0]:框 2 没了之后,框 0 和框 1、框 3 之间的竞争格局没变(框 1、3 已被 NMS 抑制),所以只剩 [0]。这说明分数过滤发生在 NMS 之前。
工程含义 :如果你用整数分数(0-100)做阈值,score_threshold=90 会把所有 90 分的框全部丢掉。这是"看起来合理但静默丢结果"的典型来源。
5.3 nms_threshold 的抑制边界
python
for t in (0.10, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.85, 0.89, 0.90, 0.93, 0.95, 0.99):
print("nms_threshold=%.2f -> %s" % (t, dnn.NMSBoxes(B, S, 0.0, t)))
text
nms_threshold=0.10 -> [0, 2]
nms_threshold=0.30 -> [0, 2]
nms_threshold=0.40 -> [0, 2]
nms_threshold=0.50 -> [0, 2]
nms_threshold=0.60 -> [0, 2]
nms_threshold=0.70 -> [0, 2]
nms_threshold=0.80 -> [0, 2]
nms_threshold=0.85 -> [0, 1, 2]
nms_threshold=0.89 -> [0, 1, 2]
nms_threshold=0.90 -> [0, 1, 2]
nms_threshold=0.93 -> [0, 3, 1, 2]
nms_threshold=0.95 -> [0, 3, 1, 2]
nms_threshold=0.99 -> [0, 3, 1, 2]
四个框的 IoU 矩阵(probe12_c 实算):
| box0 | box1 | box2 | box3 | |
|---|---|---|---|---|
| box0 (0.90) | 1.0000 | 0.8223 | 0.0000 | 0.9238 |
| box1 (0.85) | 0.8223 | 1.0000 | 0.0000 | 0.8884 |
| box2 (0.70) | 0.0000 | 0.0000 | 1.0000 | 0.0000 |
| box3 (0.88) | 0.9238 | 0.8884 | 0.0000 | 1.0000 |
三档结果对应三个 IoU 台阶:
≤ 0.80:box1(0.8223)、box3(0.9238) 都被 box0 抑制,只剩不相交的 box2 →[0, 2]0.85 ~ 0.90:box3(0.9238) 仍被抑制,但 box1(0.8223) 逃出生天 →[0, 1, 2]≥ 0.93:三个重叠框全部保留 →[0, 3, 1, 2]
注意最后一行的返回顺序 [0, 3, 1, 2] 不是按分数降序 (分数序应是 0.90 > 0.88 > 0.85 > 0.70,即 [0, 3, 1, 2]------碰巧一致)。但这说明返回索引的顺序不保证是分数序,下游代码不能依赖顺序,需要分数就自己排。
5.4 top_k 有个能复现的异常行为
top_k 是"最多保留几个"。实测发现一个反直觉的现象:
python
B4 = B
for k in (0, 1, 2, 3, 4, 5, 10):
print("top_k=%2d (4 框) -> %s" % (k, dnn.NMSBoxes(B4, S, 0.0, 0.5, 1.0, k)))
text
top_k= 0 (4 框) -> [0, 2]
top_k= 1 (4 框) -> [0]
top_k= 2 (4 框) -> [0]
top_k= 3 (4 框) -> [0]
top_k= 4 (4 框) -> [0, 2]
top_k= 5 (4 框) -> [0, 2]
top_k=10 (4 框) -> [0, 2]
看 top_k=1, 2, 3 三行------结果都是 [0];但 top_k=4 又变回 [0, 2]。top_k=2 和 top_k=3 少返回了一个框。
换 6 个框再看:
python
B6 = np.array([[10., 10., 100., 100.], [15., 15., 100., 100.],
[300., 300., 80., 80.], [12., 12., 100., 100.],
[500., 500., 60., 60.], [520., 520., 60., 60.]], np.float32)
S6 = np.array([0.9, 0.85, 0.7, 0.88, 0.6, 0.58], np.float32)
for k in (0, 1, 2, 3, 4, 5, 6, 7):
print("top_k=%2d (6 框) -> %s" % (k, dnn.NMSBoxes(B6, S6, 0.0, 0.5, 1.0, k)))
text
top_k= 0 (6 框) -> [0, 2, 3, 4, 5]
top_k= 1 (6 框) -> [0]
top_k= 2 (6 框) -> [0]
top_k= 3 (6 框) -> [0, 2]
top_k= 4 (6 框) -> [0, 2, 3]
top_k= 5 (6 框) -> [0, 2, 3, 4]
top_k= 6 (6 框) -> [0, 2, 3, 4, 5]
top_k= 7 (6 框) -> [0, 2, 3, 4, 5]
规律浮出来了:
top_k |
返回个数 |
|---|---|
| 0 | 全部(等于不限制) |
| 1 | 1 |
| 2 | 1(不是 2) |
| 3 | 2 |
| 4 | 3 |
| 5 | 4 |
| 6 | 5 |
| 7 | 5(受实际框数 5 限制) |
k≥2 时返回 k-1 个,k=1 时返回 1 个,k=0 表示不限制。6 框那一组(保留 5 个)完全吻合 k-1 规律。
我没有去读 OpenCV 的 C++ 源码定位这个 off-by-one 的成因(这一篇不做源码考古),但行为是稳定可复现的,两组数据独立验证了同一规律。
工程结论:top_k 在 5.0.0 上不要用。 它换来的是"少一个框"这种静默行为------而检测结果少一个框,可能是漏检,可能是正常,只是你不会知道。想要数量上限,自己切片:
python
# ✅ 显式控制数量,不依赖 top_k 的语义
idx = dnn.NMSBoxes(B, S, 0.25, 0.45)
idx = sorted(idx, key=lambda i: -float(S[i]))[:50] # 自己排序 + 截断
顺带把 5.3 的"顺序不保证"也一起解决了------显式排序。
5.5 eta 必须在 (0, 1],0 会崩
eta 是 hard NMS 的衰减系数(1.0 = 纯 hard NMS):
python
for e in (0.0, 0.1, 0.3, 0.6, 0.9, 0.99, 1.0):
try:
print("eta=%.2f -> %s" % (e, dnn.NMSBoxes(B, S, 0.0, 0.5, e)))
except Exception as ex:
print("eta=%.2f -> !! %s" % (e, str(ex).split("error:")[-1].strip()[:60]))
text
eta=0.00 -> !! (215:Assertion failed) eta > 0
eta=0.10 -> [0, 2]
eta=0.30 -> [0, 2]
eta=0.60 -> [0, 2]
eta=0.90 -> [0, 2]
eta=0.99 -> [0, 2]
eta=1.00 -> [0, 2]
eta=0 触发 C++ 断言 eta > 0。这是个合理的设计------0 意味着分数瞬间归零、后续比较失去意义,与其算出无意义结果不如直接拒绝。
但它和 5.6 的参数个数陷阱叠在一起就很难查:
text
dnn.NMSBoxes(B, S, 0.0, 0.5, 0) # 6 个位置参数 → eta=0 → 断言崩溃
dnn.NMSBoxes(B, S, 0.0, 0.5, 1.0, 0) # 7 个位置参数 → top_k=0 → 不限制
同一个 0,位置不同,语义完全不同。
5.6 参数个数:3 个参数会报 TypeError,不是运行错误
python
try:
dnn.NMSBoxes(B, S, 0.0)
except TypeError as e:
print("3 参数 -> TypeError:", str(e)[:80])
try:
dnn.NMSBoxes(B, S, 0.0, 0.5, 1.0, 2.5) # top_k 传 float
except TypeError as e:
print("top_k=float -> TypeError:", str(e)[:80])
text
3 参数 -> TypeError: NMSBoxes() takes at least 4 arguments (3 given)
top_k=float -> TypeError: argument 'top_k': required method argument to be an integer, not supplied
nms_threshold 是必填 。而 top_k 必须是 Python int------传 2.5 是 TypeError,传 np.int64(5) 也会失败(Python 绑定不接受 numpy 整数做整型参数,需要 int(k) 转一下)。
这类错误好在它报错清晰。真正难查的是 5.4 那种"不报错但语义诡异"的情况。
5.7 我在这里推翻了自己一次:softNMSBoxes 要的是"整数",不是 int32
第一次测 softNMSBoxes,我把框给了 float32,失败;换成 int32,成功。于是我在 probe12_c 里写下结论:"softNMSBoxes 只接受 int32"。
这个结论是错的。 我后来为了写这一章去穷举 dtype,发现 int32 只是"第一个试成功的":
text
dtype NMSBoxes NMSBoxesBatched softNMSBoxes
float32 OK OK 拒绝
float64 OK OK 拒绝
int32 OK OK OK
int64 OK OK OK
uint8 OK OK OK
text
softNMS dtype=uint8 OK -> (array([0.9, 0.21981905], float32), array([0, 1], int32))
softNMS dtype=int16 OK -> (array([0.9, 0.21981905], float32), array([0, 1], int32))
softNMS dtype=int32 OK -> (array([0.9, 0.21981905], float32), array([0, 1], int32))
softNMS dtype=int64 OK -> (array([0.9, 0.21981905], float32), array([0, 1], int32))
正确的规律是:
NMSBoxes/NMSBoxesBatched:接受任意数值 dtype,浮点整数都行;softNMSBoxes:接受任意整数 dtype,一律拒绝浮点。
也就是说,真正的规则是"整数 vs 浮点",我原来的"int32"是把一个具体实例当成了规则。
而这个错误有实际后果。 如果按我原来的错误结论写代码,你会写出 .astype(np.int32)。但真实需求是:
python
# ✅ 规则是"整数",按你自己的精度需要选宽度
boxes_i = np.round(boxes).astype(np.int32) # 常规图像坐标
boxes_i = np.round(boxes).astype(np.int64) # 超大图 / 避免溢出
如果图像宽度超过 21 亿像素,int32 会溢出------虽然那不现实,但 int32 的选择本身是任意的,正确做法是理解规则后按需选择。
顺带一个隐蔽的坑 :uint8 也能通过,但那不代表它可用 。上面测试的框坐标都小于 255,所以没暴露问题。真实 1920×1080 图的 x=1920 在 uint8 里直接溢出成 64。
"dtype 被接受"和"dtype 合适"是两件事。 这也是我要写这段的原因------测试数据太小的时候,类型错误会伪装成正确。
5.8 NMSBoxesRotated 在 Python 侧完全不可用
OBB 模型输出的是旋转框,所以直觉上该用 NMSBoxesRotated。在 5.0.0 的 Python 绑定上,它对任何输入都报错。
旋转框格式是 4 个角点 + 中心,共 5 个 (x, y):
python
pts = [[10., 10.], [110., 10.], [110., 110.], [10., 110.], [60., 60.]]
pts2 = [[12., 12.], [112., 12.], [112., 112.], [12., 112.], [62., 62.]]
穷举 6 种输入形式 × 2 种 scores 形式 = 12 种组合:
| 输入形式 | scores | 结果 |
|---|---|---|
| list of list of float | np.float32 | ❌ |
| list of list of float | python list | ❌ |
| list of list of np.float64 | np.float32 | ❌ |
| list of list of np.float64 | python list | ❌ |
| tuple of tuple | np.float32 | ❌ |
| tuple of tuple | python list | ❌ |
float32 ndarray (1,2,5,2) |
np.float32 | ❌ |
float32 ndarray (1,2,5,2) |
python list | ❌ |
float64 ndarray (1,2,5,2) |
np.float32 | ❌ |
float64 ndarray (1,2,5,2) |
python list | ❌ |
int32 ndarray (1,2,5,2) |
np.float32 | ❌ |
int32 ndarray (1,2,5,2) |
python list | ❌ |
text
成功组合数 0 / 12
12 种全部是同一个错:
text
TypeError: Can't parse 'bboxes'. Sequence item with index 0 has a wrong type
这里我要再承认一次自己的错误。 probe12_c 11 里我测了 float32 和 int32 两种 ndarray,都失败,却把判读写成了"只接受 int32"------判读和数据自相矛盾。我当时应该立刻警觉,但没有。
补测的 probe12_h 2 把结论钉死:不是 dtype 问题,是这个函数在 Python 侧实际不可用 。所有输入形式都被 cv::dnn::sortByScore 之前的参数解析拒绝。
那 OBB 模型怎么办? 这一篇我只给到"实测可用的边界",不给未经验证的替代方案。可选方向有两条(都需要你在自己的环境上验证):
- 回到
NMSBoxes------把 OBB 的 4 角点转成最小外接轴对齐框(cv2.minAreaRect+cv2.boxPoints),牺牲旋转信息换可用性; - 绕过
cv2.dnn,用 ONNX Runtime 侧的 NMS,或自己在 numpy 里实现旋转框 IoU。
我不写哪一种"更好",因为我这一篇没有对它们做实测。
5.9 再推翻一次:空输入不抛异常,长度不匹配才抛
probe12_c 8 里我写过一句"空输入不是返回空列表而是抛 C++ 断言"。这句话也不准确。 补测的 probe12_h 4 把两种情况分开测了:
| 函数 | 输入 | 结果 |
|---|---|---|
NMSBoxes |
boxes=(0,4) scores=(0,) |
✅ () |
NMSBoxes |
boxes=[] scores=[] |
✅ () |
NMSBoxes |
boxes=(0,4) scores=[] |
✅ () |
NMSBoxes |
boxes=[] scores=(0,) |
✅ () |
NMSBoxes |
1 项 / 0 项 | ❌ 断言 |
NMSBoxes |
0 项 / 1 项 | ❌ 断言 |
NMSBoxesBatched |
三者全空 | ✅ () |
NMSBoxesBatched |
1 项 / 0 项 | ❌ 断言 |
softNMSBoxes |
boxes=(0,4) scores=(0,) |
✅ ((), ()) |
softNMSBoxes |
1 项 / 0 项 | ❌ 断言 |
text
NMSBoxes 长度不匹配 → OpenCV(5.0.0) ...\modules\dnn\src\nms.cpp:37 (-215:Assertion failed)
NMSBoxesBatched 长度不匹配 → ...\nms.cpp:112 (-215:Assertion failed)
softNMSBoxes 长度不匹配 → ...\nms.cpp:128 (-215:Assertion failed)
规则是:两者都空 → 正常返回空结果;长度不一致 → 抛断言。
我原来那句错的判读,根源是测试没区分"都空"和"长度不匹配"。我用的是空 list,只记录了抛异常的那一次,没想过"两边的空 list 长度相同"这个分支。
顺带一个收获 :三个函数的断言分别在 nms.cpp 的 37 / 112 / 128 行------不同行号说明是三处独立的检查,不是共用一个前置校验。这也解释了为什么空输入能过、长度不匹配才炸。
工程做法:
python
# ✅ 判空要判"长度不一致",不是判"是否为空"
if len(boxes) != len(scores):
raise ValueError("boxes(%d) 与 scores(%d) 长度不一致" % (len(boxes), len(scores)))
idx = dnn.NMSBoxes(boxes, scores, 0.25, 0.45) # 全空时安全返回 ()
从模型输出直接取索引时,长度不匹配几乎不会发生(都来自同一个 (N, 4+C) 数组)。但如果你在中间做过过滤(比如先按分数筛,再按类别筛),两次筛选的布尔掩码长度对不上,就会命中这个断言。用同一个掩码切片 boxes 和 scores 是最省事的防御。
5.10 softNMSBoxes 的高斯 vs 线性衰减
softNMS 不删除框,而是衰减重叠框的分数。两个衰减方式:
python
B32 = np.array([[10, 10, 100, 100], [15, 15, 100, 100],
[12, 12, 100, 100], [15, 15, 100, 100]], np.int32)
S4 = np.array([0.9, 0.85, 0.88, 0.7], np.float32)
for m, nm in ((dnn.SOFT_NMSMETHOD_SOFTNMS_GAUSSIAN, "GAUSSIAN"),
(dnn.SOFT_NMSMETHOD_SOFTNMS_LINEAR, "LINEAR")):
sc, idx = dnn.softNMSBoxes(B32, S4, 0.0, 0.45, -1, 0.5, m)
print("%-9s 分数 %s 索引 %s" % (nm, ["%.4f" % v for v in sc], idx))
text
GAUSSIAN 分数 ['0.9000', '0.7000', '0.2198', '0.0329'] 索引 [0, 2, 1, 3]
LINEAR 分数 ['0.9000', '0.7000', '0.1510', '0.0075'] 索引 [0, 2, 1, 3]
同一个框在两种方式下分数不同(0.2198 vs 0.1510),但索引集合相同。
softNMS 的输出有两个要点:
- 它返回
updated_scores,不是 bool mask。 用法是拿更新后的分数自己再筛:
python
sc, idx = dnn.softNMSBoxes(boxes_i, scores, 0.25, 0.45)
keep = [(i, float(s)) for i, s in zip(idx, sc) if s >= 0.25]
top_k在这里的语义又不一样 (第 5 个参数位置)。我传-1表示不限制。这个参数我没有系统测过所有取值,所以不给结论 ,只说明用-1是可行的。
高斯 vs 线性怎么选:高斯衰减更平滑(重叠越多降得越狠但连续),线性在超过阈值后线性归零。密集重叠场景(很多相邻同类目标)用 soft-NMS 通常比 hard-NMS 少漏检,因为它不会一次性丢掉整片重叠框。代价是分数不再可比------你不能拿 soft-NMS 的 0.4 和 hard-NMS 的 0.4 当同一标准。
5.11 三个函数的最小可用模板
把本章所有约束收敛成一段可以直接抄的代码:
python
import cv2, numpy as np
dnn = cv2.dnn
def nms_xyxy(boxes_xyxy, scores, score_thr=0.25, nms_thr=0.45, top_n=100):
"""输入 xyxy + float 分数,返回 [(索引, 分数), ...](分数降序、已截断)。
避开了本文所有已验证的坑:
- 自行转 float32(不要 int)
- 不用 top_k(5.4 的 off-by-one)
- 自己排序(5.3 返回顺序不保证)
- 长度不匹配提前抛(5.9 的 C++ 断言)
"""
b = np.asarray(boxes_xyxy, np.float32).reshape(-1, 4)
s = np.asarray(scores, np.float32).reshape(-1)
if len(b) != len(s):
raise ValueError("boxes(%d) 与 scores(%d) 长度不一致" % (len(b), len(s)))
if len(b) == 0:
return []
xywh = np.stack([b[:, 0], b[:, 1],
b[:, 2] - b[:, 0], b[:, 3] - b[:, 1]], axis=1)
idx = dnn.NMSBoxes(xywh, s, float(score_thr), float(nms_thr))
idx = sorted(idx, key=lambda i: -float(s[i]))[:top_n]
return [(int(i), float(s[i])) for i in idx]
reshape(-1, 4) 和 reshape(-1) 不是为了好看------空数组也能正确 reshape,这样空输入自然走 if len(b) == 0: return [] 分支,不需要特判。
C++ 侧对照 (dtype 规则在 C++ 侧是 Mat 的 type 约束,不会抛 Python 的 TypeError):
cpp
// 未编译:本机没有 OpenCV 5.0.0 C++ SDK,本块未经编译验证,仅作对照。
#include <opencv2/dnn.hpp>
std::vector<int> nmsXywh(const std::vector<cv::Rect>& boxes,
const std::vector<float>& scores,
float scoreThr, float nmsThr, std::size_t topN) {
std::vector<cv::Rect> b = boxes;
std::vector<float> s = scores;
CV_Assert(b.size() == s.size()); // 长度不一致在 C++ 侧同样是断言(见 5.9)
// ✅ hard NMS:float32 即可(5.7 实测 NMSBoxes 接受任意数值 dtype)
std::vector<int> idx;
cv::dnn::NMSBoxes(b, s, scoreThr, nmsThr, 1.0f, 0, &idx);
// ↑topK=0 表示不限制
// ❗不要依赖 topK 的数量语义:5.4 实测 k>=2 时返回 k-1 个,存在 off-by-one。
// ✅ soft NMS:框必须是整数类型(5.7 实测一律拒绝浮点)
std::vector<cv::Rect> bi;
for (const auto& r : b) bi.emplace_back(r.x, r.y, r.width, r.height);
std::vector<float> decayed;
cv::dnn::softNMSBoxes(bi, s, scoreThr, nmsThr, -1, 0.5f,
cv::dnn::SoftNMSMethod::SOFT_NMSMETHOD_LINEAR,
&decayed, &idx);
return idx;
}
为什么 C++ 侧同样要避开 topK :5.4 实测的 off-by-one 是 NMSBoxes 自身的语义问题,不是 Python 绑定的包装问题------绑定只是把参数原样透传。所以 C++ 里 topK=2 同样少给你一个框。 上面的代码传 topK=0(不限制),再自己在返回后排序截断,和 5.11 的 Python 模板是同一个策略。
这段模板只覆盖 XYWH + hard NMS + 浮点框这一条最常用的路。 旋转框、soft-NMS、批量多类别的用法分别受 5.8 / 5.10 / 5.2 的约束限制,用之前回看一下对应小节。
六、QR 闭环:1 像素/模块的输出,为什么解码器读不出来
前五章都在讲 DNN。这一章回到传统任务------用同一个模块(objdetect)里完全不含 DNN 的 API,做一个编码→解码闭环,并且把失效边界测出来。
选 QR 有两个原因:它足够简单(成功就是解出原文),而且它完美对照 DNN 那一侧的结论。
6.1 5.0.0 的编码器必须用 create(Params())
QRCodeEncoder 的构造方式和 4.x 不同:
python
import cv2
enc = cv2.QRCodeEncoder
print("公开成员:", [m for m in dir(enc) if not m.startswith("_")])
print("有 setParams:", hasattr(enc, "setParams"))
print("有 create :", hasattr(enc, "create"))
text
公开成员: ['Params', 'create', 'encode', 'encodeStructuredAppend']
有 setParams: False
有 create : True
没有 setParams。 而且无参构造后直接 encode 会抛一个没有信息的 C++ 异常:
python
e = cv2.QRCodeEncoder()
try:
qr = e.encode("HELLO-12")
except Exception as ex:
print("!!", ex)
text
!! Unknown C++ exception from OpenCV code
Unknown C++ exception from OpenCV code 是最难查的一类报错------它没告诉你任何原因。唯一线索是"必须先 create"。5.0.0 的正确写法:
python
enc = cv2.QRCodeEncoder.create(cv2.QRCodeEncoder.Params())
qr = enc.encode("HELLO-12")
print("shape:", qr.shape, "dtype:", qr.dtype,
"唯一值:", np.unique(qr).tolist())
text
shape: (25, 25) dtype: uint8 唯一值: [0, 255]
为什么这个 API 变更值得单独写 :4.x 的教程是 QRCodeEncoder().setParams(p).encode(s)。在 5.0.0 上这行代码有三种坏法------setParams 不存在(AttributeError)、无参构造后 encode 抛 Unknown C++ exception、或者你抄到的是新写法但环境是旧的。API 迁移期的报错质量取决于你踩在哪一格,这一格特别差,因为它把原因藏起来了。
6.2 Params 只有 4 个字段
python
P = cv2.QRCodeEncoder.Params()
print("可用属性:", [m for m in dir(P) if not m.startswith("_")])
for k, v in (("version", 3), ("correction_level", 1), ("mode", 4), ("structure_number", 1)):
setattr(P, k, v)
print("设 %-16s = %s 读回 %s" % (k, v, getattr(P, k)))
text
可用属性: ['correction_level', 'mode', 'structure_number', 'version']
设 version = 3 读回 3
设 correction_level = 1 读回 1
设 mode = 4 读回 4
设 structure_number = 1 读回 1
我顺手试了几个"按 4.x 经验猜"的字段名,全部 AttributeError:
text
设 ec_level !! AttributeError: 'cv2.QRCodeEncoder.Params' object has no attribute 'ec_level' and no __dict__ for setting new attributes
设 mask !! AttributeError: ... has no attribute 'mask' ...
设 eci !! AttributeError: ... has no attribute 'eci' ...
设 min_version !! AttributeError: ... has no attribute 'min_version' ...
设 max_version !! AttributeError: ... has no attribute 'max_version' ...
设 correction_level_L !! AttributeError: ... has no attribute 'correction_level_L' ...
字段名就是 correction_level ,不是 ec_level。这一条很容易踩------很多第三方封装(包括一些 QR 库的中文资料)用 ec_level,而 OpenCV 官方是 correction_level。
因为 Params 没有 __dict__,多打一个字符就是 AttributeError 而不是静默忽略。 这算好事:错就是错,不会像 4.3 节那样"调用成功但无效"。
相关常量(注意两套名字都存在,值相同):
text
cv2.QRCODE_ENCODER_CORRECT_LEVEL_L 0 cv2.QRCodeEncoder_CORRECT_LEVEL_L 0
cv2.QRCODE_ENCODER_CORRECT_LEVEL_M 1 cv2.QRCodeEncoder_CORRECT_LEVEL_M 1
cv2.QRCODE_ENCODER_CORRECT_LEVEL_Q 2 cv2.QRCodeEncoder_CORRECT_LEVEL_Q 2
cv2.QRCODE_ENCODER_CORRECT_LEVEL_H 3 cv2.QRCodeEncoder_CORRECT_LEVEL_H 3
cv2.QRCodeEncoder_MODE_AUTO -1
cv2.QRCodeEncoder_MODE_NUMERIC 1
cv2.QRCodeEncoder_MODE_ALPHANUMERIC 2
cv2.QRCodeEncoder_MODE_STRUCTURED_APPEND 3
cv2.QRCodeEncoder_MODE_BYTE 4
cv2.QRCodeEncoder_MODE_ECI 7
cv2.QRCodeEncoder_MODE_KANJI 8
两套常量名并存 (QRCODE_ENCODER_* 和 QRCodeEncoder_*)是我没预料到的。值一样,所以混用不会算错,但新代码统一用 cv2.QRCodeEncoder_*------它和类名大小写一致,读代码时一眼能对上。
6.3 version 控制边长:边长 = 21 + 4v
QR 规格里版本 v 的符号数是 17 + 4v。而 OpenCV 的输出额外包含 4 模块的白色静区 ,所以边长是 21 + 4v:
python
enc = cv2.QRCodeEncoder.create(cv2.QRCodeEncoder.Params())
for v in (-1, 0, 1, 2, 3, 4, 5, 6, 10, 20, 40):
p = cv2.QRCodeEncoder.Params()
p.version = v
p.correction_level = cv2.QRCodeEncoder_CORRECT_LEVEL_M
try:
q = enc.encode("HELLO-12", p) if _takes_params else None
except ...
实测(载荷固定 'HELLO-12'):
version |
输出边长 | 公式 21+4v |
|---|---|---|
| -1 | ❌ 抛 (-5:Bad argument) The given version is not suitable for the given input string length |
--- |
| 0 | 25 | 21(自动→选 v1) |
| 1 | 25 | 25 ✅ |
| 2 | 29 | 29 ✅ |
| 3 | 33 | 33 ✅ |
| 4 | 37 | 37 ✅ |
| 5 | 41 | 41 ✅ |
| 6 | 45 | 45 ✅ |
| 10 | 61 | 61 ✅ |
| 20 | 101 | 101 ✅ |
| 40 | 181 | 181 ✅ |
10 个版本全部精确命中 21 + 4v。
三个要点:
version=0是"自动选最小可用版本" ,不是"版本 0"。载荷HELLO-12只有 8 个字符,自动选到 v1 → 边长 25。version=-1非法 ,抛qrcode_encoder.cpp:388的(-5:Bad argument)。这个报错信息其实很到位------它告诉你 version 与载荷长度不匹配。version只能往大指定 。如果你给一个偏小的 version,载荷装不下,同样报 "not suitable for the given input string length"。所以version是下界约束,不是精确指定。
为什么"边长 = 21+4v"这个公式值得记:它是这一章后面所有放大实验的坐标基准。有了它,你能从输出边长反推版本:
python
def version_of(side):
"""由输出边长反推 QR 版本。边长 = 21 + 4v。"""
d = side - 21
return None if d % 4 else d // 4
实测载荷扫描全部可用这个函数反推成功:
| 载荷长度 | 输出边长 | 反推版本 |
|---|---|---|
| 1 / 5 / 10 / 17 / 20 | 25 | 1 |
| 30 | 29 | 2 |
| 40 / 60 | 33 | 3 |
| 80 | 37 | 4 |
| 100 | 41 | 5 |
| 150 | 45 | 6 |
| 200 | 53 | 8 |
| 500 | 77 | 14 |
| 1000 | 105 | 21 |
20 字符以内都是 v1 ,说明 v1 的容量远大于 'HELLO-12' 这种短载荷。这也解释了 6.1 那个 Unknown C++ exception:它跟容量无关,纯粹是缺 create。
关于纠错等级 :把 correction_level 从 0(L)扫到 3(H),四种等级输出边长都是 25 。因为 8 字符载荷在 v1 下四种等级都装得下。所以"换纠错等级会不会让码变大"这个问题,在短载荷上是观察不到的------要看到差异,得用 6.3 这张表里 1000 字符那种接近容量上限的载荷。这一条我不下"换高纠错一定不变大"的结论,因为我没在长载荷上测。
6.4 输出是 1 像素/模块,而且带 4 模块静区
python
q = enc.encode("HELLO-12")
print("shape:", q.shape, "dtype:", q.dtype)
print("唯一值:", np.unique(q).tolist())
print("首行前 8 个像素:", q[0, :8].tolist())
print("第 4 行前 8 个像素:", q[4, :8].tolist())
quiet = 0
for r in range(q.shape[0]):
if (q[r] == 255).all(): quiet += 1
else: break
print("静区宽度(上方连续全 255 行数):", quiet)
text
shape: (25, 25) dtype: uint8
唯一值: [0, 255]
首行前 8 个像素: [255, 255, 255, 255, 255, 255, 255, 255]
第 4 行前 8 个像素: [255, 255, 0, 255, 0, 0, 0, 255]
静区宽度(上方连续全 255 行数): 4
三个关键性质:
- 像素值只有
0和255,没有灰度过渡------这正是"1 像素/模块"的直接体现。 - 静区是白色
255,宽 4 模块,符合 QR 规格。首行全 255,第 4 行就已经进入数据区(有 0)。 - 没有
setParams也没有"模块像素数"参数 。编码器给你的是逻辑栅格,不是可打印的图像。
为什么第 3 点是这一章的核心 :我原本以为会有个类似 module_size 或 pixel_size 的参数可以直接放大。实测 5.0.0 的 Params 里没有这个概念(6.2 列的 4 个字段里没有)。
所以要得到一张"人眼能看、能打印、能被检测器读"的 QR 图,放大是你自己的责任 。这个责任从编码器转移到了调用方------而 6.5 会证明,这个责任不是可选项。
6.5 1 像素/模块的原生输出,解码器读不出来
闭环的第一环就断了:
python
det = cv2.QRCodeDetector()
print("detectAndDecode(原生 25x25):", repr(det.detectAndDecode(q)[0]))
print("detect(原生 25x25) 返回类型:", type(det.detect(q)))
try:
print("decode(原生 25x25):", repr(det.decode(q)[0]))
except Exception as e:
print("decode(原生 25x25) -> !!", str(e).split("error:")[-1].strip()[:90])
text
detectAndDecode(原生 25x25): ''
detect(原生 25x25) 返回类型: tuple
decode(原生 25x25) -> !! (215:Assertion failed) CV_MAT_CN(stype) == CV_MAT_CN(dtype) in function 'cv::Mat::copyTo'
三个入口三种表现:
| 入口 | 结果 |
|---|---|
detectAndDecode |
返回 ''(空字符串,不报错) |
detect |
返回 tuple(成功检测到四角) |
decode |
❌ 抛 copy.cpp:457 断言 |
最危险的是第一种。 detectAndDecode 返回空字符串,意味着"没解出来",但你无法区分 "图里没码"和"码在那儿但像素太密"。它在两种情况下返回一模一样的 ''。
而 decode 抛的是 copyTo 的通道数断言(CV_MAT_CN(stype) == CV_MAT_CN(dtype)),报错位置在 core/src/copy.cpp,跟 QR 没有任何字样 。这个断言的真实含义是"我需要一个已经定位好的、与 qr 通道数一致的 ROI",但错误文本完全没提这件事。
为什么这一节值得单独写 :detectAndDecode 的空字符串 + decode 的无关报错,构成一个很差的调试组合。你看到 '' 时的第一反应通常是"码生成错了吧",于是去检查 version、correction_level、mode------而真正的原因是每个模块只有 1 个像素。
这就是我下面要测的东西。
6.6 放大到 2 像素/模块就能解 ------ 唯一稳定的规律
用最近邻做整数倍放大,扫描可解码阈值:
python
for k in (1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20):
up = cv2.resize(q, (q.shape[1] * k, q.shape[0] * k),
interpolation=cv2.INTER_NEAREST)
s, _, _ = det.detectAndDecode(up)
print("NEAREST x%-2d 放大 %2dx -> shape=%-12s 解出=%r"
% (k, k, str(up.shape), s))
text
NEAREST x1 放大 1x -> shape=(25, 25) 解出=''
NEAREST x2 放大 2x -> shape=(50, 50) 解出='HELLO-12'
NEAREST x3 放大 3x -> shape=(75, 75) 解出='HELLO-12'
NEAREST x4 放大 4x -> shape=(100, 100) 解出='HELLO-12'
NEAREST x5 放大 5x -> shape=(125, 125) 解出='HELLO-12'
NEAREST x6 放大 6x -> shape=(150, 150) 解出='HELLO-12'
NEAREST x8 放大 8x -> shape=(200, 200) 解出='HELLO-12'
NEAREST x10 放大 10x -> shape=(250, 250) 解出='HELLO-12'
NEAREST x12 放大 12x -> shape=(300, 300) 解出='HELLO-12'
NEAREST x16 放大 16x -> shape=(400, 400) 解出='HELLO-12'
NEAREST x20 放大 20x -> shape=(500, 500) 解出='HELLO-12'
x1 失败,x2 到 x20 全部成功------阈值就在 2 像素/模块。
为什么是 2 而不是别的 :QR 的定位靠三个定位图案(finder pattern)的相邻模块黑白跳变。1 像素/模块时,检测器需要在单像素尺度上判断黑白,采样和插值误差直接破坏跳变。放大到 2 像素/模块后,每个模块有 2×2 的像素块,跳变有了 2 个像素的容差。
这个 2 是不是普适的? 我不能说"任何场景 2 像素/模块就够"------我的测试载荷是 8 字符(v1,25×25),静区 4 模块。边长越大,定位图案在整图里的相对占比越小 ,需要的最小模块像素数可能更高。载荷更长 → 版本更高 → 边更大 → 需要更多像素/模块。 这个推论我没有实测,读者可以在自己的载荷长度上跑一遍 6.6 这个扫描。
6.7 插值方式与倍率没有单调关系 ------ 这条结论有点反直觉
如果"最近邻最好"是个简单规律,那 6.6 就够了。实测不是:
| 倍率 | NEAREST | LINEAR | CUBIC | AREA | LANCZOS4 |
|---|---|---|---|---|---|
| x2 | ✅ | ❌ | ❌ | ✅ | ❌ |
| x4 | ✅ | ❌ | ✅ | ✅ | ✅ |
| x8 | ✅ | ✅ | ✅ | ✅ | ❌ |
text
判读: 结果不服从「只有最近邻能过」:2x 时 AREA 也能过而 LANCZOS4 失败,
8x 时 LANCZOS4 反而失败。插值方式与放大倍率没有单调关系,唯一稳定成立的是 1x(不放大)必失败。
看这张表:
- x2 时 LANCZOS4 失败,x8 时 LANCZOS4 也失败,但 x4 时成功;
- x2 时 LINEAR 失败,x8 时成功;
- CUBIC 在 x2 失败、x4 和 x8 成功。
如果存在一个"最佳插值方式",它应该在所有倍率下都最好。实测不存在。 唯一稳定的规律是 6.6 那条:1x 必失败,2x 以上多数组合可用。
为什么会有这种非单调性 :检测器内部有多个环节------定位图案扫描、二值化、透视变换、采样。任何一种插值引入的灰度过渡落在哪个环节、是否跨越二值化阈值,取决于该环节的具体实现和当次的模块/像素比例。这种跨环节的相位效应不是一个可以用"插值平滑度"解释的单变量关系。
工程做法(这条是实践结论,不是理论结论):
python
# ✅ 用最近邻放大,并在必要时按 6.6 扫描确认
up = cv2.resize(q, None, fx=4, fy=4, interpolation=cv2.INTER_NEAREST)
s, pts, _ = det.detectAndDecode(up)
if not s:
# 失败就往上加倍率,别换插值方式碰运气
for k in (8, 12, 16):
s, pts, _ = det.detectAndDecode(
cv2.resize(q, None, fx=k, fy=k, interpolation=cv2.INTER_NEAREST))
if s: break
为什么不推荐用 6.7 的表去做"选最优插值" :这张表是在一个 载荷(v1)、一张 图(纯白底、无噪声)上测的。它的价值在于排除"最近邻是唯一选择"这个错误假设 ,而不是提供一张选型表。放大倍率才是稳定可控的那个变量(6.6),插值不是。
6.8 缩小方向:模块像素数不足,必然失效
反方向扫描:
python
for k in (0.90, 0.75, 0.50, 0.40, 0.30, 0.25, 0.20):
dn = cv2.resize(q, None, fx=k, fy=k, interpolation=cv2.INTER_NEAREST)
s, _, _ = det.detectAndDecode(dn)
print("NEAREST x%.2f 缩小%.2f -> shape=%-12s 解出=%r" % (k, k, str(dn.shape), s))
text
NEAREST x0.90 缩小0.90 -> shape=(22, 22) 解出=''
NEAREST x0.75 缩小0.75 -> shape=(19, 19) 解出=''
NEAREST x0.50 缩小0.50 -> shape=(12, 12) 解出=''
NEAREST x0.40 缩小0.40 -> shape=(10, 10) 解出=''
NEAREST x0.30 缩小0.30 -> shape=(8, 8) 解出=''
NEAREST x0.25 缩小0.25 -> shape=(6, 6) 解出=''
NEAREST x0.20 缩小0.20 -> shape=(5, 5) 解出=''
7 个倍率全部失败,包括 0.90(22×22,只掉了 3 像素)。
为什么 0.90 就已经失效了 :边长 22 除以 21+4v 反推,(22-21)/4 = 0.25,不是整数------它已经不是合法的 QR 边长了。所以失败是必然的,不是"精度不够"。
这条实测的实际含义 :不要试图缩小 QR 图来省内存/带宽 。缩小的代价不是"分辨率下降导致偶尔解不出",而是结构被破坏,必然解不出 。INTER_NEAREST 在缩小 0.90 时就已经把某些模块吞掉或复制了,破坏的是编码结构本身。
对比 6.6 的放大------放大是增加冗余像素 ,结构不变,所以安全。这两条合起来是一条明确的方向性结论:QR 只能放大,不能缩小。
6.9 20 组退化鲁棒性:JPEG 全过,倾斜 15° 就崩
以 8x 最近邻(可解码)为基准,施加 20 种退化:
| 退化 | 解码 | 检出角点 |
|---|---|---|
| 原图 8x | ✅ | 有 |
| 顺时针 90° | ✅ | 有 |
| 旋转 90°(warpAffine) | ✅ | 有 |
| 旋转 180° | ✅ | 有 |
| 倾斜 5° | ✅ | 有 |
| 倾斜 15° | ❌ | 无 |
| 倾斜 30° | ❌ | 无 |
| 倾斜 45° | ❌ | 无 |
| 高斯噪声 σ=10 | ❌ | 无 |
| 高斯噪声 σ=30 | ❌ | 无 |
| 高斯噪声 σ=60 | ❌ | 无 |
| 高斯模糊 k=5 | ✅ | 有 |
| 高斯模糊 k=15 | ✅ | 有 |
| JPEG q=90 | ✅ | 有 |
| JPEG q=50 | ✅ | 有 |
| JPEG q=10 | ✅ | 有 |
| JPEG q=2 | ✅ | 有 |
| 对比度压到 40% | ✅ | 有 |
| 叠加 50% 亮度偏移 | ✅ | 有 |
| 嵌入大画布一角(280×300) | ✅ | 有 |
text
矩阵成功率 14 / 20
四条能站住的规律:
1. 旋转无关。 90°、180°、任意 warpAffine 旋转都不影响。QR 本身有定位图案,方向信息编码在格式信息里。
2. 倾斜容忍度在 5°~15° 之间,且是硬失效。 5° 过,15° 就崩。而且失败时**"检出角点"那一列也是"无"------不是"检测到了但解码错",是连定位都没成功**。
3. 高斯噪声极不宽容(σ=10 就崩),但高斯模糊很宽容(k=15 都过)。 这看起来反直觉,但方向是对的:
- 噪声在模块内部制造随机的黑白跳变,直接干扰定位图案的 1:1:3:1:1 比例判定 → 定位失败。
- 模糊是低通滤波,把模块内的跳变平滑成过渡,模块中心仍保留正确取值 → 只要不过度模糊,定位不受影响。
4. JPEG 极度宽容,q=2 都过。 这是最意外的一条。q=2 是极低质量的 JPEG(严重块效应 + 色度下采样),通常足以让任何视觉检测失效。
为什么 JPEG 这么宽容而噪声这么致命 :这是我对这组结果唯一能给出的解释------QR 模块是纯黑白二值,JPEG 的块效应主要破坏高频细节,而模块内部的取值本来就已经是"全 0"或"全 255",没有高频信息可破坏 。加上纠错等级 M 本身就能扛约 15% 的码字损坏。反过来,随机噪声是全频段的,它在模块内部制造的是本不存在的跳变。
但我要收住这个解释 :这是我基于实测结果的推测,没有做对照实验 (比如"噪声 σ=10 + 提高纠错等级到 H"是否能过)。如果你的场景噪声大,实测一下提高 correction_level 有没有用,别信我的推论。
工程做法 :倾斜容限只有 ±5°,噪声容限几乎为零。如果你的码要出现在倾斜或高噪环境,用 detectAndDecodeCurved(6.10 有它的耗时)或者直接上专用解码库,不要指望调 correction_level 能救几何问题。
6.10 检测器耗时 vs 手写传统四步
detect 是最贵的入口:
python
for nm, fn in (("detect", lambda: det.detect(q8)),
("decodeAndDecode", lambda: det.decodeAndDecode(q8)),
("detectAndDecodeCurved", lambda: det.detectAndDecodeCurved(q8)),
("decode", lambda: det.decode(q8))):
print("%-24s %.4f ms/次" % (nm, bench(fn, n=10, warm=2)))
text
detect 19.3285 ms/次
decodeAndDecode 21.9241 ms/次
detectAndDecodeCurved 23.2054 ms/次
decode 0.0222 ms/次
decode 只要 0.0222 ms,比 detect 快 870 倍。 因为 decode 假设你已经知道码在哪、并且已经把图裁好了------它跳过了整个定位流程。
对照手写传统四步(OTSU 二值化 → findContours → boundingRect,只定位不解码):
text
OTSU+findContours+boundingRect (8x 图, 200x200) 0.1379 ms/次,检出 11 个轮廓
OTSU+findContours+boundingRect (原生 25x 图, 25x25) 0.0381 ms/次,检出 11 个轮廓
0.1379 ms vs 19.3285 ms------专用检测器贵 140 倍。
而且注意:手写四步在原生 25×25 图上也能检出 11 个轮廓 (虽然那是 11 个噪声轮廓,不是真正的码------25×25 的图上 OTSU + findContours 必然找到一堆小连通域)。所以"检出 11 个轮廓"不等于"检出 QR 码" ,这恰恰说明了专用检测器贵在哪里------它做的不是"找到矩形",而是"找到矩形并且确认它是 QR 并且采样正确"。
为什么这一组数字是全文的收束 :把 6.10 的 detect(19.33 ms)和第四章的检测模型前向(41.75 ms)放在一起:
| 任务 | 耗时 |
|---|---|
| 手写四步定位(不解码) | 0.1379 ms |
QRCodeDetector.detect |
19.3285 ms |
QRCodeDetector.decode(已知位置) |
0.0222 ms |
| YOLO 分类模型前向 | 4.6498 ms |
| YOLO 检测模型前向 | 41.7502 ms |
readNet(一次性) |
184.25 ms |
没有哪一种方案是最快的。 快的是"任务匹配的方案":已知位置的 QR 解码 0.02 ms,要通用检测就 19 ms,要输出类别和框就 41 ms。第六章的分工判断,答案就在这张表里。
6.11 分工判断:什么时候该上 DNN,什么时候传统流水线才是正确答案
副标题里承诺的答案,现在用全文的数字来给。
先把所有实测耗时放在一张表里(18 线程,OpenCV 5.0.0,本文各章):
| 任务 | 耗时 | 出处 |
|---|---|---|
QRCodeDetector.decode(已知位置,已裁好) |
0.0222 ms | 6.10 |
cvtColor 640×640 |
0.1176 ms | 4.5 |
| 手写 OTSU+findContours+boundingRect(不解码) | 0.1379 ms | 6.10 |
findContours |
0.1957 ms | 4.5 |
| OTSU 阈值 | 0.4216 ms | 4.5 |
Canny 边缘 |
0.4683 ms | 4.5 |
resize →640 |
0.7458 ms | 4.5 |
| YOLO 分类模型前向 | 4.6498 ms | 4.2 |
QRCodeDetector.detect(未知位置,通用) |
19.3285 ms | 6.10 |
QRCodeDetector.detectAndDecodeCurved |
23.2054 ms | 6.10 |
| YOLO 检测模型前向 | 41.7502 ms | 4.4 |
readNet(一次性启动成本) |
184.25 ms | 4.4 |
| 首次前向(含图优化,不要用来做预算) | 5254.80 ms | 4.1 |
三条判断规则:
规则一:DNN 的价值不在"更快",在"输出类别和置信度"。
传统四步定位只要 0.1379 ms,DNN 检测要 41.75 ms------慢 300 倍。但传统四步输出的是"11 个矩形",DNN 输出的是"5 个目标、类别、置信度、位置"。
如果你需要"这个缺陷是什么、有多严重",传统算子给不了,DNN 是唯一选项。 反过来,如果你只需要"哪里有东西",传统四步便宜三个数量级。
规则二:位置已知时,永远不要用 detect。
decode 0.0222 ms vs detect 19.3285 ms,870 倍。这个比值比 DNN 和传统算子之间的差距还大。
工程含义 :如果你的流水线里已经有 ROI(比如已经按工位裁好、或者已经有传统检测的候选框),把 ROI 喂给 decode 而不是 detectAndDecode ,能省掉 99% 的时间。这一条对规则一是个补充------传统检测和 DNN 不是二选一,可以串联:
python
# ✅ 便宜的传统检测负责"找出候选",便宜的 decode 负责"读内容"
cands = threshold_and_find_contours(roi_gray) # ~0.14 ms
for (x, y, w, h) in cands: # 候选通常很少
sub = roi_gray[y:y+h, x:x+w]
s, _, _ = det.decode(cv2.copyMakeBorder(sub, 20, 20, 20, 20,
cv2.BORDER_CONSTANT, value=255))
if s: yield (x, y, w, h, s)
为什么这个组合值得写 :它把"19.33 ms 的通用检测"换成了"0.14 ms 定位 + N × 0.02 ms 解码"。候选数 N 通常是个位数,所以总耗时在 0.3 ms 量级。但它只在"码的位置大致可知、且不会漏"的前提下成立 ------手写四步不像专用检测器那样确认"这是 QR"(6.10 实测在 25×25 图上也能报 11 个轮廓,全是噪声)。候选多到 N 上百时,这个组合反而比 detectAndDecodeMulti 慢。
规则三:把启动成本和运行成本分开算。
readNet 184.25 ms 是一次性 的,前向 41.75 ms 是每帧 的。第七章之前的工程实践里,很多"DNN 太慢"的结论其实是"每帧重读模型"造成的(4.4 节的 ❌ 代码)。
判断标准很简单:
python
# ✅ 这个决策只看两个数
n_frames_per_net_load = 1e9 # 你打算复用同一个 net 处理多少帧?
read_net_ms = 184.25
infer_ms = 41.75
print("单帧摊销成本: %.4f ms" % (read_net_ms / n_frames_per_net_load + infer_ms))
| 复用帧数 | 单帧总成本 | 相对最优 |
|---|---|---|
| 1 | 227.42 ms | 5.45x |
| 10 | 60.17 ms | 1.44x |
| 100 | 43.59 ms | 1.04x |
| ∞ | 41.75 ms | 1.00x |
"相对最优"那一列是除以 ∞ 行(即单帧前向 41.75 ms)算的,所以 1 帧那行是 227.42 / 41.75 = 5.45x。
复用 100 帧之后,读网成本摊薄到 4.4% 以下,基本可以忽略。 而复用 1 帧时它占了 81.5%。
所以"要不要缓存模型"这个问题的答案不是"要",而是"你的复用帧数是不是大于 10"。 单帧推理的场合(一次性工具、离线批处理)缓存也帮不上忙------因为只有一个 net 生命周期。
一条不该被忽略的反向判断:
第三章的 52 格矩阵里,13 个检测模型只有 2 个能在 5.0.0 上跑起来,YOLOv11/v12 全部因为 Reshape2 断言和 ConcatLayer 形状不一致失败。如果你的模型在这张矩阵里是不可运行的那一类,那么"传统流水线 vs DNN"这个讨论对你不成立------你根本没有可用的 DNN 选项。
这是第三章那张矩阵真正的价值 :它不是"性能对比表",它是**"你的模型能不能上这条技术路线"的准入检查**。先过准入,再谈性能。
避坑指南
本文实测踩到的坑,按"坑的隐蔽程度"排序。前五个会静默出错,后面的会明确报错。
| # | 坑 | 症状 | 正确做法 | 出处 |
|---|---|---|---|---|
| 1 | blobRectsToImageRects 无视 letterbox padding |
不报错、不越界 ,框系统性偏 55 px ;p.size 未设时静默返回 INT_MIN |
letterbox 下按 sc/pad 自己换算,并 np.clip |
2.8 |
| 2 | setPreferableTarget 空操作 |
调用成功、无数值变化、stderr 刷 WARN | 5.0.0 换引擎只能靠 readNet 第 4 参数;target 无效 |
3.2 |
| 3 | 检测输出漏 sigmoid | 阈值形同虚设,959/2100 候选过闸 | 逐模型用 out.min()<0 or out.max()>1.001 判断 |
3.7 |
| 4 | reshape 轴选错 |
不报错,读出"2095 个类别"这种荒谬值 | reshape(-1, shape[1]),并用 anchor 数自检 |
3.6 |
| 5 | top_k off-by-one |
top_k=2 只返回 1 个框,不报错 |
不用 top_k,自己排序 + 切片 |
5.4 |
| 6 | 输入尺寸与导出 imgsz 不符 |
416/512/800/1024 → Reshape2 断言;640 能跑但候选数仍是 2100 |
从模型自身确认输入尺寸,别猜 | 3.5 |
| 7 | 用首次前向时间做性能预算 | 5254.80 ms vs 稳态 4.6498 ms,差 1130 倍 | 必须 warmup ≥3 轮 | 4.1 |
| 8 | 每帧 readNet |
单帧 227.42 ms 而非复用后的 43.17 ms,慢 5.3x | readNet 放启动期,Net 可重复 setInput |
4.4 |
| 9 | getPerfProfile 按 4.x 解包 |
TypeError: 'int' object is not subscriptable |
5.0.0 返回 (int, ndarray) 2-tuple |
4.3 |
| 10 | softNMSBoxes 用浮点框 |
Can't parse 'bboxes' |
要整数 dtype(任意整型皆可),不是浮点 | 5.7 |
| 11 | NMSBoxesRotated |
12 种输入形式全部失败 | Python 侧不可用;转外接矩形或换运行时 | 5.8 |
| 12 | boxes/scores 长度不匹配 | nms.cpp:37/112/128 断言 |
用同一个掩码切片两者 | 5.9 |
| 13 | score_threshold 是开区间 |
分数恰好等于阈值被丢弃 | score > thr;整数分数慎用 == 边界 |
5.2 |
| 14 | eta=0 崩 |
Assertion failed eta > 0 |
eta ∈ (0, 1],1.0 即 hard NMS |
5.5 |
| 15 | QRCodeEncoder 无参构造 |
Unknown C++ exception from OpenCV code(无信息) |
必须 create(Params()) |
6.1 |
| 16 | QR 输出 1 像素/模块 | detectAndDecode 返回 '',decode 抛 copy.cpp 无关断言 |
放大到 ≥2 像素/模块 | 6.5、6.6 |
| 17 | 缩小 QR 图 | 必然失效(0.90 倍起就崩) | QR 只能放大,不能缩小 | 6.8 |
| 18 | cv2.data 里找 cascade XML |
数量为 0 | 5.0.0 不再内置级联分类器数据 | 1.6 |
| 19 | HOGDescriptor |
hasattr 为 False,API 已移除 |
5.0.0 无此接口 | 1.6 |
优秀实践
一、启动成本与运行成本分层。
python
class Detector:
"""net 在启动期构造一次,运行期只复用。Net 可重复 setInput,不是一次性对象。"""
def __init__(self, model, size, engine=None):
dnn = cv2.dnn
self.size = size
self.net = (dnn.readNet(model) if engine is None
else dnn.readNet(model, "", "", engine))
self.thr, self.nms = 0.25, 0.45
def __call__(self, img):
blob = cv2.dnn.blobFromImage(img, 1.0 / 255.0, self.size,
swapRB=True, crop=False,
dtype=cv2.CV_32F)
self.net.setInput(blob)
return self.net.forward()
为什么这个分层重要 :4.4 实测 readNet 是单次前向的 4.4 倍。把它和 forward 放在同一个函数里,等于每次调用都付 5.3 倍的成本(4.4 节表格)。
二、接入新模型时跑一次"三问自检"。
python
def probe_model(path, size):
"""接入任何 ONNX 前的准入检查。三问:能读吗?输出是概率还是 logit?候选数对吗?"""
dnn = cv2.dnn
net = dnn.readNet(path)
b = dnn.blobFromImage(np.zeros((size[1], size[0], 3), np.uint8),
1.0 / 255.0, size, swapRB=True, dtype=cv2.CV_32F)
net.setInput(b)
o = net.forward() # 问一:这里就会抛
print("问一 能跑: yes, 输出", o.shape)
print("问二 需 sigmoid:", bool(o.min() < 0 or o.max() > 1.001))
cand = np.prod(o.shape[1:])
exp = sum((size[0] // s) ** 2 for s in (8, 16, 32))
print("问三 候选数 %d vs 理论 %d:" % (cand, exp), cand == exp)
为什么要有这个 :第三章的 52 格矩阵和 3.5 的尺寸扫描说明,"模型能不能用"是一个独立于代码正确性的问题。这段自检 3 行就能挡掉 3.3(模型不兼容)、3.7(logit)、3.5/3.6(尺寸与布局)三类坑。
三、warmup 是所有计时的前置动作,不是优化技巧。
python
def bench(fn, n=20, warm=3):
"""任何 OpenCV 计时都要 warmup。首次调用含一次性图优化(4.1 实测差 1130 倍)。"""
for _ in range(warm):
fn()
t0 = time.perf_counter()
for _ in range(n):
fn()
return (time.perf_counter() - t0) * 1000.0 / n
四、profile 之后先看数组求和是否自洽。
python
prof = net.getPerfProfile()
times = np.asarray(prof[1], np.float64).ravel()
assert abs(times.sum() / float(prof[0]) - 1.0) < 1e-6, "profile 与总时间不匹配"
names = net.getLayerNames()
assert times.size == len(names), "层时间数与层名数不等,不能按位置对齐"
为什么这两个断言值得加 :4.3 实测 sum(prof[1]) / prof[0] == 1.0000、times.size == len(names) == 95。这两个等式成立说明你拿到的是同一次前向的完整数据;不成立说明中间又跑了 forward 或数组被覆盖。它们是免费的、无副作用的自检。
五、静音 OpenCV 的 C++ 层 WARN。
python
cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_FATAL)
为什么必须做 :3.2 实测一次 13×4 循环刷 6 行 WARN。cv2.dnn.setLogLevel 在 5.0.0 已不存在,唯一的入口是 cv2.utils.logging 。不静音的话,WARN 会混在你的输出里,且它不会 通过 try/except 出现------它是静默的功能失效,不是异常。
可改进之处
一、我的探针有 4 处判读被我自己的数据推翻了。
这是本文最该改进的地方。4 处都发生在"我先写下结论、后来发现数据不支持":
probe12_c11 我写"softNMSBoxes只接受 int32"------实际是"接受任意整数 dtype"(uint8/int16/int32/int64 全过)。- 同一个
probe12_c11 我把NMSBoxesRotated也写成"只接受 int32"------实际是 12/12 全部失败 ,Python 侧不可用。这两条出自同一节判读,是两处独立的错(方法论一节按两处计)。 probe12_c8 我写"空输入一律抛断言"------实际是"两者都空返回(),只有长度不匹配才抛"。- 本文 3.5 我一度写"模型只接受 320 输入"------实际 640 也能前向(候选数仍是 2100,框坐标错)。
共同的错误模式 :我先形成了一个过度概括的结论 ,然后只寻找支持它的证据,没有回头检查所有 数据行。补测的 probe12_h 234 就是因为我注意到"判读行"和"数据行"打架才写的。
改进方向 :探针的每个"判读"应该在写完后立刻逐行核对,而不是等到写文章时才回头。另外第 4 条是另一类错误------单点测试被当成边界,对策是关键参数至少扫 3 个值(详见方法论一节的分类表)。
二、top_k 的 off-by-one 没有定位根因。
5.4 我给出了可复现规律(k≥2 时返回 k-1 个)并验证了两组数据,但没有读 OpenCV 的 C++ 源码确认成因 。这个 off-by-one 是"少一个框"的静默行为,值得一个确定的答案。我在这一篇里选择不做源码考古,但读者如果要用 top_k,应该自己去看 modules/dnn/src/nms.cpp。
三、倾斜容限没有精确定位。
6.9 只测了 5°/15°/30°/45°,得出"容限在 5°~15° 之间"。真正的边界(比如 8°、10°、12°)没测 ,所以这个区间比实际宽。二分法几轮就能收敛,我没做。
四、噪声容限没有对照实验。
6.9 观察到"σ=10 噪声就崩,但 JPEG q=2 都过",我给了物理解释(模块是纯黑白二值 + 纠错 M)。但我没有验证"提高 correction_level 到 H 能不能让噪声测试通过" ------如果能,那说明失败原因是码字损坏而非定位失败;如果不能,说明是定位阶段就崩了。这两种机制对应完全不同的工程解法(前者调码,后者调预处理)。
五、OBB 的旋转框 NMS 缺实测替代方案。
5.8 我确认了 NMSBoxesRotated 不可用,然后列出两条替代方向但都没测 。这一篇的立场是"不给未验证的答案",但这意味着 OBB 流水线是断的,读者需要自己在 ONNX Runtime 侧或 numpy 里补上。
六、version 与纠错等级的交互没测。
6.3 我在 8 字符载荷上测出"四种纠错等级边长都是 25",并明确说了"不能推广到长载荷 "。1000 字符(v21)那种接近容量上限的载荷下,高纠错等级是否显著增大边长、长载荷下最小模块像素数是否需要超过 2------这两个问题都需要实测。
工程实践
把全文压成一份可执行的检查清单。
接入一个 ONNX 模型,按这个顺序做:
python
import cv2, numpy as np
dnn = cv2.dnn
# 步骤 0:静音 C++ 层 WARN(3.2)。必须在任何 net 操作之前。
cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_FATAL)
# 步骤 1:确认模型在目标 OpenCV 版本上能读(3.3 的 52 格矩阵是准入检查)
net = dnn.readNet(MODEL, "", "", dnn.ENGINE_AUTO) # 引擎走第 4 参数
# 读网就失败 → 模型不兼容,换模型或换版本,不要继续往下调
# 步骤 2:确认输入尺寸(3.5)。别猜,从导出记录或扫描里拿。
SIZE = (320, 320) # YOLOv8-OBB 实测只有 320 几何正确
# 步骤 3:blob 预处理(第二章)。四个参数逐个确认,不要靠默认。
blob = dnn.blobFromImage(img, scalefactor=1.0 / 255.0, size=SIZE,
swapRB=True, crop=False, dtype=cv2.CV_32F)
# 步骤 4:前向 + 判断输出口径(3.7、3.8)。逐模型验,不靠直觉。
net.setInput(blob)
out = net.forward()
need_sigmoid = bool(out.min() < 0 or out.max() > 1.001)
# 步骤 5:布局自检(3.6)。轴选错不报错,用 anchor 数当场抓住。
flat = out.reshape(-1, out.shape[1])
exp = sum((SIZE[0] // s) ** 2 for s in (8, 16, 32))
assert flat.shape[0] == exp, "reshape 轴可能选错:%d vs 理论 %d" % (flat.shape[0], exp)
# 步骤 6:置信度过滤在 sigmoid 之后(3.7)
scores = flat[:, 5:]
if need_sigmoid:
scores = 1.0 / (1.0 + np.exp(-scores))
conf = scores.max(axis=1)
keep = conf >= 0.25 # 注意是严格大于(5.2)
# 步骤 7:NMS(第五章)。用第五节的模板,不要用 top_k / NMSBoxesRotated。
选型决策,按这个顺序问:
- 位置已知吗? 已知 →
QRCodeDetector.decode(0.0222 ms)。未知 → 传统四步定位(0.1379 ms)+decode,或者detectAndDecodeMulti(6.11 规则二)。 - 需要类别和置信度吗? 不需要 → 传统算子(0.1~0.7 ms/步)。需要 → DNN(4.65 ms 分类 / 41.75 ms 检测)(6.11 规则一)。
- 模型在 5.0.0 上能跑吗? 不能 → 没有"选不选 DNN"这个问题,先解决兼容性(6.11 反向判断、3.3)。
- 复用多少帧? ≥10 帧 → 缓存
net(4.4)。<10 帧 → 缓存收益 <4.4%,不必为它设计(6.11 规则三)。
三条硬性纪律:
| 纪律 | 依据 |
|---|---|
| 计时前 warmup ≥3 轮 | 首次前向 5254.80 ms vs 稳态 4.6498 ms |
readNet 只在启动期 |
单次前向的 4.4 倍 |
| 不写未实测的精度数字 | 3.9:前向成功 ≠ 模型可用 |
算子选型速查表
cv2.dnn 引擎(5.0.0 实测)
| 想做的事 | 写法 | 实测耗时 | 出处 |
|---|---|---|---|
| 自动选引擎 | readNet(p) |
基准 4.6498 ms | 4.2 |
| 旧引擎(别用) | readNet(p,"","",ENGINE_CLASSIC) |
10.9222 ms,慢 2.35x | 4.2 |
| 新引擎 | readNet(p,"","",ENGINE_NEW) |
4.6149 ms | 4.2 |
| ONNX Runtime | readNet(p,"","",ENGINE_ORT) |
4.3837 ms,最快 | 4.2 |
| 选 target | ❌ 无效,5.0.0 忽略 | --- | 3.2 |
| 静音 WARN | cv2.utils.logging.setLogLevel(...) |
--- | 3.2 |
预处理
| 场景 | 写法 | 出处 |
|---|---|---|
| 标准分类预处理 | blobFromImage(img, 1/255., (224,224), swapRB=True, crop=False, dtype=CV_32F) |
3.4 |
| 自定义归一化 | blobFromImageWithParams(img, p),p 里 swapRB=True |
第二章 |
| 手动 NCHW(跳过 permute 层) | x.transpose(2,0,1)[None] + ascontiguousarray |
4.3 |
后处理
| 场景 | 写法 | 硬约束 | 出处 |
|---|---|---|---|
| 单类 hard NMS | NMSBoxes |
框 float32 ;不用 top_k;自己排序 |
5.11 |
| 多类 NMS | NMSBoxesBatched |
传 class_ids,类间不抑制 |
签名 |
| soft NMS | softNMSBoxes |
框必须整数 dtype;返回 (scores, idx) |
5.7、5.10 |
| 旋转框 NMS | NMSBoxesRotated |
❌ Python 侧不可用(0/12) | 5.8 |
QR
| 场景 | 写法 | 硬约束 | 出处 |
|---|---|---|---|
| 编码 | QRCodeEncoder.create(Params()).encode(s, p) |
无 setParams;字段是 correction_level |
6.1、6.2 |
| 已知位置解码 | det.decode(roi) |
0.0222 ms;ROI 通道数须一致 | 6.5、6.10 |
| 通用解码 | det.detectAndDecode(img) |
须 ≥2 像素/模块 | 6.6 |
| 多码 | det.detectAndDecodeMulti(img) |
4.x 的 detectAndDecodeMultiple 已移除 |
6.1 |
| 倾斜/弯曲 | det.detectAndDecodeCurved(img) |
23.2054 ms,比标准慢 | 6.10 |
传统对照
| 操作 | 耗时 | 出处 |
|---|---|---|
cvtColor |
0.1176 ms | 4.5 |
findContours |
0.1957 ms | 4.5 |
| OTSU | 0.4216 ms | 4.5 |
Canny |
0.4683 ms | 4.5 |
resize 640 |
0.7458 ms | 4.5 |
方法论:这一篇我把"验证"本身测错了 4 次
前面五章讲的都是"我怎么测出 OpenCV 的坑"。但这一篇真正让我改掉习惯的,是我在自己的探针里犯的错。
本文所有数字都来自 14 组探针 :probe12_a ~ probe12_h 八组支撑正文主干,probe12_i ~ probe12_n 六组是写 2.8 节时因为踩坑而补的。它们的结构是一样的:逐行打印实测数据,最后写一段"判读"总结。问题出在那个"判读"上------它是人写的,而数据是机器给的,两者不保证一致。
第一次:softNMSBoxes 的 dtype 规则。
probe12_c 的原始测法是"float32 失败、int32 成功",我就写下了"softNMSBoxes 只接受 int32"。到写第五章时我想确认一下 uint8 行不行,一测就发现 uint8、int16、int64 全过。真正的规则是"整数 vs 浮点",不是"int32"。
如果我当初多测两种整型,这个错误根本不会发生。而它会造成实际后果 :按"必须 int32"写代码,你会去做一个 .astype(np.int32)------一个没有任何理由的窄化。
第二次:NMSBoxesRotated 的可用性。
同一个 probe12_c 的 11 节,我测了 float32 ndarray 和 int32 ndarray,两者都失败 ,然后判读写的是"只接受 int32"。判读行和我自己打印的数据行直接打架,我居然没有当场发现。
补测的 probe12_h 2 穷举了 6 种输入形式 × 2 种 scores 形式 = 12 种,0/12 全部失败。真实结论是"这个函数在 Python 侧不可用",而不是任何 dtype 规则。
第三次:空输入的语义。
probe12_c 8 的判读是"空输入抛 C++ 断言,不是返回空列表"。probe12_h 4 把两种情况分开测:
- 两者都空 →
NMSBoxes返回(),不抛; - 长度不一致 →
nms.cpp:37/112/128断言。
我原来的判读把"长度不一致"当成了"空输入"。根源是我用空 list 只测了一次,没区分"都空"和"一个空一个不空"。
第四次:模型输入尺寸。
3.5 节我一度写"这个模型只接受 320 输入",因为第一次试的确实是 320。后来把 320/416/512/640/800/1024 跑成一张矩阵才发现:320 和 640 都能过,其他四个挂在 Reshape2 断言上。而更糟的是 640 虽然能过,输出仍是 2100 个候选(3.5 节),框坐标还是错的。
这一次和前三次性质不同 :前三次是"判读和数据打架",这一次是**"我只试了一个点,就把一个点当成了边界"**。矩阵一铺开,边界立刻清楚了------单点测试最容易制造fake边界。
这四次其实是两类错误,方法论不一样:
| 前三次 | 第四次 | |
|---|---|---|
| 错误形态 | 判读与数据矛盾 | 单点测试被当成边界 |
| 为什么没发现 | 判读是文字,和数据不在一处 | 单点结果自洽,看不出问题 |
| 对策 | 判读写完逐行核对 | 关键参数至少扫 3 个值 |
除了这四次,还有一次不属于上面任何一类,值得单独说 :2.8 节我写"blobRectsToImageRects 是解 NMS 框回原图坐标的正解,比手算缩放比例可靠"。**这不是判读错,也不是单点测试------是我凭印象记错了函数签名和它的适用边界。**补测(probe12_i ~ probe12_n)发现它只做纯 resize 逆变换、letterbox 下 y 向系统性偏 55 px、p.size 未设时静默返回 INT_MIN。这类错误比前四类更危险:前四类至少还有数据行或矩阵摆在面前逼你复核,而"记错签名"在你不去实跑的那一天永远不会暴露。
四次的共同点:
- 都是过度概括------从一两个数据点推出一个比数据更强的规则;
- 都在判读行,不在数据行------所以代码没报错、文件正常生成、我不觉得有问题;
- 都是在写文章时才被发现------因为写文章要引用具体规则,"这个结论到底成不成立"变成了必须回答的问题。
我现在的做法(这是这一篇真正的收获):
- 探针里先只写数据,判读留到最后单独写;
- 写完判读逐行回头核对,看有没有哪一行数据我没解释,或者反过来;
- 结论里凡出现"总是""只接受""必然"这类词,就回去检查是不是有反例行被忽略了;
- 发现矛盾时不改判读去迁就数据,也不改数据去迁就判读,而是补测 ------
probe12_h就是这么来的,它的存在理由就是"解一个已知的矛盾"。
还有一个副作用值得说 :probe12_h 不是我一开始就计划写的。它是因为发现判读和数据打架才临时补的 ,专门用来收敛一个矛盾。一个探针存在的理由是"我要回答一个具体问题",而不是"我要覆盖一个 API 面"。 盲目追求覆盖率会写出像 probe12_c 8 那样"测了但不准确"的结论。
本期互动
这一篇实测下来,我最想听大家聊的是第三章那张 52 格矩阵。
我的机器上 13 个检测模型只有 2 个能在 5.0.0 跑起来,YOLOv11/v12 的 OBB 版本因为 Reshape2 断言和 ConcatLayer 形状不一致全军覆没,_cpu_int8 变体在 readNet 阶段就挂。我很想知道你们的环境是什么情况:
- 你们手上的 ONNX 模型在 OpenCV 5.0.0 上能跑吗?
- 如果跑不了,报错和我这里一样吗(
(-5:Bad argument) residual added after convolution/(-201) Inconsistent shape for ConcatLayer)? - 你们是留在 4.x,还是迁到了 5.0.0?
另外第六章 6.9 的倾斜容限 也想听听:我在 ±5° 能过、±15° 就崩,这个边界看起来很窄。如果你们的相机装得歪一点,QR 检测是不是就完全不可用了? 有没有试过 detectAndDecodeCurved?
💬 评论区聊聊
三个问题,挑一个答就行:
-
你踩过
setPreferableTarget静默失效吗? 3.2 说它在 5.0.0 是 no-op------调用成功、不报错、无数值变化。如果你的项目里已经有一行setPreferableTarget,你会不会怀疑它其实一直没起作用? -
你的
readNet在哪里被调用? 4.4 实测读网是单次前向的 4.4 倍。有没有人把readNet写在循环里、或者每帧重新readNet的?用grep -rn "readNet" .搜一下自己的项目,答案可能让你意外。 -
传统四步你会用在哪? 6.11 的规则二说"位置已知就别用 detect",这意味着传统检测 +
decode串联的方案能用 0.3 ms 搞定通用检测要 19.33 ms 的事。你的场景里位置是已知的吗? 如果是,这招值不值得试?
👉 收藏 · 转发 · 系列目录
这一篇的信息密度比较高------14 组探针(正文引用 probe12_a~h 八组,2.8 节补测 i~n 六组)、52 格矩阵、19 条避坑、五处自我推翻(4 处判读被数据推翻 + 1 处签名记错)。建议先收藏,需要查某个 API 时再回来翻对应小节。
如果你身边有人正在从 OpenCV 4.x 迁 5.0.0,这一篇的第三章和第五章能省他不少时间 。setPreferableTarget 失效、HOGDescriptor 被移除、cv2.data 里 0 个级联文件、getPerfProfile 返回值变了、top_k 的 off-by-one------这些都不会给你一个像样的报错。