OpenCV 实战第 12 篇:dnn + objdetect 推理,以及传统算子没死

上篇回顾

第十一篇讲 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,就想问:那到底什么时候还值得用传统算子?

能力承诺

读完这一篇你会拿到:

  1. 一张 5.0.0 的 dnn / objdetect API 生死表,附 readNet 失败时的真实报错文本。
  2. blobFromImage 的全部参数语义,特别是 mean 会跟着 swapRB 一起换位这个坑。
  3. 一个 13 模型 × 4 引擎 = 52 格的兼容性矩阵,以及为什么只有 2 个模型能跑。
  4. 一套能用的推理代码骨架:选引擎、warmup、计时、逐层剖析。
  5. NMSBoxes 家族的阈值边界、dtype 陷阱和 top_k 异常实测。
  6. QRCodeEncoder / QRCodeDetector 闭环与失效边界(模块像素数实测)。
  7. 一张传统 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']

注意两点:

  1. 是 scalefactor ,不是 scale。
  2. 没有 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

三个观察:

  1. 候选数恒为 2100,跟输入尺寸无关 。320 输入理论上是 (320/8)² + (320/16)² + (320/32)² = 40² + 20² + 10² = 1600 + 400 + 100 = 2100,正好。640 输入本该是 80² + 40² + 20² = 6400 + 1600 + 400 = 8400,实际还是 2100。
  2. 失败的都是 reshape2_layer.cpp 的 autoSize*outTotal == inpTotal 断言------reshape 的元素总数对不上。
  3. 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 模型怎么办? 这一篇我只给到"实测可用的边界",不给未经验证的替代方案。可选方向有两条(都需要你在自己的环境上验证):

  1. 回到 NMSBoxes------把 OBB 的 4 角点转成最小外接轴对齐框(cv2.minAreaRect + cv2.boxPoints),牺牲旋转信息换可用性;
  2. 绕过 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 的输出有两个要点:

  1. 它返回 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]
  1. 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。

三个要点:

  1. version=0 是"自动选最小可用版本" ,不是"版本 0"。载荷 HELLO-12 只有 8 个字符,自动选到 v1 → 边长 25。
  2. version=-1 非法 ,抛 qrcode_encoder.cpp:388 的 (-5:Bad argument)。这个报错信息其实很到位------它告诉你 version 与载荷长度不匹配。
  3. 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

三个关键性质:

  1. 像素值只有 0 和 255,没有灰度过渡------这正是"1 像素/模块"的直接体现。
  2. 静区是白色 255,宽 4 模块,符合 QR 规格。首行全 255,第 4 行就已经进入数据区(有 0)。
  3. 没有 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 处都发生在"我先写下结论、后来发现数据不支持":

  1. probe12_c 11 我写"softNMSBoxes 只接受 int32"------实际是"接受任意整数 dtype"(uint8/int16/int32/int64 全过)。
  2. 同一个 probe12_c 11 我把 NMSBoxesRotated 也写成"只接受 int32"------实际是 12/12 全部失败 ,Python 侧不可用。这两条出自同一节判读,是两处独立的错(方法论一节按两处计)。
  3. probe12_c 8 我写"空输入一律抛断言"------实际是"两者都空返回 (),只有长度不匹配才抛"。
  4. 本文 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。

选型决策,按这个顺序问:

  1. 位置已知吗? 已知 → QRCodeDetector.decode(0.0222 ms)。未知 → 传统四步定位(0.1379 ms)+ decode,或者 detectAndDecodeMulti(6.11 规则二)。
  2. 需要类别和置信度吗? 不需要 → 传统算子(0.1~0.7 ms/步)。需要 → DNN(4.65 ms 分类 / 41.75 ms 检测)(6.11 规则一)。
  3. 模型在 5.0.0 上能跑吗? 不能 → 没有"选不选 DNN"这个问题,先解决兼容性(6.11 反向判断、3.3)。
  4. 复用多少帧? ≥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。这类错误比前四类更危险:前四类至少还有数据行或矩阵摆在面前逼你复核,而"记错签名"在你不去实跑的那一天永远不会暴露。

四次的共同点:

  1. 都是过度概括------从一两个数据点推出一个比数据更强的规则;
  2. 都在判读行,不在数据行------所以代码没报错、文件正常生成、我不觉得有问题;
  3. 都是在写文章时才被发现------因为写文章要引用具体规则,"这个结论到底成不成立"变成了必须回答的问题。

我现在的做法(这是这一篇真正的收获):

  • 探针里先只写数据,判读留到最后单独写;
  • 写完判读逐行回头核对,看有没有哪一行数据我没解释,或者反过来;
  • 结论里凡出现"总是""只接受""必然"这类词,就回去检查是不是有反例行被忽略了;
  • 发现矛盾时不改判读去迁就数据,也不改数据去迁就判读,而是补测 ------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?

💬 评论区聊聊

三个问题,挑一个答就行:

  1. 你踩过 setPreferableTarget 静默失效吗? 3.2 说它在 5.0.0 是 no-op------调用成功、不报错、无数值变化。如果你的项目里已经有一行 setPreferableTarget,你会不会怀疑它其实一直没起作用?

  2. 你的 readNet 在哪里被调用? 4.4 实测读网是单次前向的 4.4 倍。有没有人把 readNet 写在循环里、或者每帧重新 readNet 的?用 grep -rn "readNet" . 搜一下自己的项目,答案可能让你意外。

  3. 传统四步你会用在哪? 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------这些都不会给你一个像样的报错。

相关推荐
骑着蜗牛撵大象3271 小时前
云原生存储进阶:CSI 演进与 AI 工作负载的存储架构设计
人工智能·ai·云原生·kubernetes·csi·并行文件系统
菩提树下的凡夫1 小时前
OPenCV4.8与OpenCV5.0的区别
c++·opencv
Martina_03211 小时前
AI生成3D模型怎么判断能不能继续编辑?用6步检查Mesh、材质、UV与导出文件
图像处理·人工智能·3d·ai·自然语言处理·uv·游戏策划
shxjnpl1 小时前
小米 CocktailASR 带来的启示:当语音识别开始区分“谁在说话”,ASR 技术正在发生什么变化?
人工智能·语音识别
4SAPI1 小时前
Provider Adapter设计模式:如何让AI应用支持多个模型
网络·人工智能·设计模式
枯木◊靠推文躺平版1 小时前
2026 企业 AI 办公平台怎么选:选型框架与主流产品全景
大数据·人工智能
NeoGressAI外贸数字化1 小时前
外贸独立站被AI引用怎么测?4段脚本搭一套抽样
人工智能
loulanyue_1 小时前
不生成文本的AI?新型Jev模型与System 1决策工作流深度解析——读IBM Master Inventor Martin Keen前沿架构
人工智能·架构
Sand(ContextGate)1 小时前
从开发者调试工具到消费级 App:一个 AI 电商导购项目的 C 端改造与 6 个连环踩坑实录
开发语言·人工智能·python·ai·langchain