
上篇回顾
第 11 篇我们守住了"帧是连续的"这条底线,第 12 篇把检测算法接了上去。两篇各留下一个没解决的问题:
- 第 11 篇实测
cv2.getNumThreads()默认 = 18 (等于逻辑核数),而cv2.setNumThreads(0)不是恢复默认 ,是禁用并行、getNumThreads()之后返回 1 。当时我说"想恢复默认没有直接 API"。本篇把setNumThreads的语义整组扫了一遍,有一个新事实和一个必须按负载实测的结论。 - 第 12 篇的流水线是"能跑"的,但没人管每帧分配了多少内存、计时数字可不可信、参数改一行要不要重编译 。前 12 篇每个算子单独看都很便宜,串起来呢?
这一篇就是把"能跑"升级成"不崩、可测量、可配置 "。查的过程里我发现自己写的探针错了 10 处------比正文里的 bug 还多。
本篇要解决的痛点
产线上位机的典型代码长这样:
python
while True:
frame = cap.read()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blur, 50, 150)
mask = cv2.dilate(edges, np.ones((3, 3), np.uint8))
# ... 每帧 4 个新数组
跑一个小时没事,跑一个月呢?四个问题:
- 内存为什么一直涨? 每帧 4 次分配、每次几 MB,Python 的引用计数和 NumPy 的缓存会不会把它们留住?实测增量是多少?
dst=到底能省多少? 教程说"预分配复用",但哪些算子支持dst=?不支持的怎么办?- 计时数字能信吗? 你打印的 "0.8ms" 是单次耗时 还是系统运行时长 ?不预热、只测最后一段、用错计时器,三个动作就能让你的结论完全反向。
- 线程数设几? 第 11 篇说默认 18。那我设 16 还是 8?有没有一个"全局最优值"?
所以本篇按一条工程化链 组织:拼流水线 → 算内存账 → 揪出隐形拷贝 → 设并行度 → 把计时测准 → 把参数挪出代码。每一环都给探针。
能力承诺
读完你能自己回答六个问题:
- 六阶段链式调用累计分配了多少字节 ?
dst=复用能把这个数字砍到多少?(11.865 MiB → 1.978 MiB,-83.3%) dst=是不是万能的?哪个算子不支持,实测报什么错?- "预分配复用"实测快多少?(4.042x / 7.070x )------以及为什么 GC 不是你的敌人(+0.0007 ms)
setNumThreads(999)会被钳制到核数吗?实测答案和你印象可能相反。- 同一份扫描脚本,8 线程比 18 线程快------那第 11 篇"默认 18"还算结论吗?
- 你的计时代码漏了
np.diff,误差是 3984461 倍。怎么在写第一行 benchmark 就避开?
一、把前 12 篇拼成一条流水线
1.1 六阶段链:每个算子都是熟人,串起来才是新问题
前 12 篇讲过的算子,随便挑六个就能拼一条"从彩色图到掩膜"的最小流水线:
python
g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
g = cv2.GaussianBlur(g, (5, 5), 0)
g = cv2.Canny(g, 50, 150)
g = cv2.dilate(g, np.ones((3, 3), np.uint8))
g = cv2.erode(g, np.ones((3, 3), np.uint8))
g = cv2.threshold(g, 127, 255, cv2.THRESH_BINARY)[1]
每一步单独看都无害:cvtColor 第 3 篇讲过、GaussianBlur 第 4 篇、Canny 第 7 篇、形态学第 4 篇、阈值第 5 篇。但"每一步都返回一个新数组"这件事,前面没有任何一篇算过总账。
1.2 字节账:朴素链式的中间缓冲是输入的 2 倍(probe13_a[A3])
1920×1080 输入,逐阶段累计分配:
| 阶段 | 字节 |
|---|---|
| cvtColor | 2073600 |
| GaussianBlur | 2073600 |
| Canny | 2073600 |
| dilate | 2073600 |
| erode | 2073600 |
| threshold | 2073600 |
| 累计 | 12441600(11.865 MiB) |
输入 img 是 6220800 字节(5.933 MiB),输出 2073600 字节。中间缓冲累计是输入的 2.00 倍------注意这里 6 个中间结果恰好全是单通道 8 位,所以每级都是 2073600。
更关键的是 probe13_c[C3] 的一句判读:持有全部 7 个引用后,id() 去重 7/7 ------每一步都是新建对象,没有任何复用。链式调用在 Python 侧就是一个小型分配流水线。
这里我犯过一次错,方法论第 3 条 :第一版我按
id()去重得到 5/7,写下了"存在对象复用"。持有引用后 7/7,结论反了 ------临时对象被 GC 后地址会被复用,id()去重是假象。
1.3 哪些优化只有 C++ 侧拿得到
cpp
// 未编译:本机无 OpenCV C++ SDK,按官方签名推演
// 1) cv::Mat 是引用计数的浅拷贝:按值传参几乎零成本
void sharpen(cv::Mat& img, const cv::Mat& kernel) { /* 就地 */ }
void caller() {
cv::Mat img = cv::imread("x.png");
sharpen(img, k); // ✅ 引用传递,零拷贝
cv::Mat tmp = img.clone(); // ❌ 想要独立副本必须显式 clone
}
// 2) 但按值传参 + auto 推导就是隐形拷贝现场
auto m = cv::imread("x.png"); // auto 推成 cv::Mat,仍是浅拷贝,OK
cv::Mat m2 = img; // 浅拷贝:改 m2 的像素 = 改 img 的像素
cv::Mat m3 = img.clone(); // 深拷贝:这才是"新分配"
Python 侧没有这个问题的另一半 :NumPy 数组语义是"显式才复制"(见 §3.1),g = cv2.cvtColor(...) 永远返回新对象。两边的坑不对称:C++ 要防"以为拷了其实没拷"(浅拷贝共享数据),Python 要防"以为没拷其实拷了"(每次调用都在分配)。
std::vector 中转同理------std::vector<uchar> → cv::Mat 的转换如果按值接收,整块像素被复制一次。这类开销只有 C++ 侧存在,也只有 C++ 侧能测(本机测不了,故不给数字)。
二、内存是第一性能杀手
2.1 dst= 支持度普查:不是 6/6,是 5/6(probe13_a[A4])
"预分配复用"的前提是算子支持 dst=。我把六阶段逐个显式传 dst= 实测:
text
cvtColor dst= -> True
GaussianBlur dst= -> True
Canny dst= -> False (error)
dilate dst= -> True
erode dst= -> True
threshold dst= -> True
支持 dst= : 5/6 -> ['cvtColor', 'GaussianBlur', 'dilate', 'erode', 'threshold']
不支持 dst= : ['Canny']
cv2.Canny(buf, 50, 150, dst=buf) 实测报:
text
Canny() got an unexpected keyword argument 'dst'
Canny() missing required argument 'threshold2' (pos 4)
这里我犯过一次方法论第 10 条的错(签名凭印象记错) :我第一版探针就是按"应该都支持"直接写下 cv2.Canny(buf,50,150,dst=buf),直接 TypeError。更隐蔽的是第二版 :我改测试时把 dst= 从 Canny 那行删掉了 ,于是"6/6 支持"的假阳性 堂而皇之地打印出来------探针跑通了、结果漂亮、结论是错的 。第三版每个算子都显式传 dst= 才拿到真实的 5/6。
教训:测"支不支持 X",必须真的传 X。测试代码里删掉参数再问"支不支持",答案永远是"支持"。
2.2 混合策略:83.3% 的分配可以省掉(probe13_a[A5])
能就地的 5 段新增 0 字节,必须新分配的只有 Canny 那 1 段:
| 策略 | 累计分配 | 相对朴素 |
|---|---|---|
| 朴素链式 6 段 | 12441600 字节(11.865 MiB) | --- |
| 混合策略(5 就地 + 1 新分配) | 2073600 字节(1.978 MiB) | -83.3% |
写法:
python
buf = np.empty((h, w), np.uint8) # 灰度域缓冲,预分配一次
cv2.cvtColor(img, cv2.COLOR_BGR2GRAY, dst=buf)
cv2.GaussianBlur(buf, (5, 5), 0, dst=buf)
edges = cv2.Canny(buf, 50, 150) # ❌ Canny 不支持 dst=,这里必然新分配
cv2.dilate(edges, kernel, dst=edges) # ✅ 边缘域继续就地
注意 dst= 的一个约束 :输出必须是预分配好、形状与 dtype 都对 的数组,np.empty_like 派不上用场时要显式 np.empty((h, w), np.uint8)。probe13_a[A2] 实测:50 次等价调用,不带 dst= 新分配 50 次 ,带 dst= 新分配 0 次 (id 不变,nbytes=2073600)。
2.3 复用 vs 重建:4.042x 与 7.070x(probe13_b[B1][B2])
400 帧循环,只看中位数(p95 与最大值同样落盘):
| 写法 | 中位 | p95 | 最大 |
|---|---|---|---|
每帧新建(cv2.cvtColor 返回新数组) |
0.6614 ms | 0.8647 ms | 1.9772 ms |
预分配 dst= 复用 |
0.1637 ms | 0.2260 ms | 0.6361 ms |
| 加速 | 4.042x |
纯拷贝场景更夸张:
| 写法 | 中位 | 加速 |
|---|---|---|
src.copy() 每帧重建 |
0.4235 ms | --- |
np.copyto(dst, src) 预分配 |
0.0599 ms | 7.070x |
分配次数的直接证据(probe13_b[B3]):400 次调用,新建路径见到 2 个 不同缓冲区(Python 会复用刚释放的地址,所以不是 400),dst= 路径恒为 1 个。
2.4 GC 不是你的敌人(probe13_b[B4])
一个常见的过度优化是"我手动 gc.collect() 控制内存"。实测两段各 200 次调用,中间强制 GC:
text
GC 后首段中位 0.8475 ms / 再 GC 后 0.8482 ms / 差 +0.0007 ms
-> gc.collect() 对单帧 OpenCV 调用中位数影响 可忽略
+0.0007 ms,噪声级。 真正贵的不是回收时机,是分配本身(2.3 节的 4.042x)。该做的是减少分配次数,而不是插手 GC。
2.5 内存峰值:594.2 MiB,和理论值差 0.15%(probe13_b[B5])
持有 300 帧灰度图不放,实测进程 WorkingSet:
text
当前 WorkingSet 60.8 MiB / Peak 62.6 MiB
持有 300 帧灰度后 WorkingSet 654.9 MiB / Peak 654.9 MiB
增量 594.2 MiB(300 x 2073600 字节 = 593.3 MiB 理论)
释放后 WorkingSet 60.8 MiB (回收 594.1 MiB)
实测 594.2 vs 理论 593.3,差 0.15% ------NumPy 数组的像素就是明明白白躺在进程里的,"中间结果反正会被回收"是错觉,只要还持有引用它就一直在。
这一段的探针本身错了一次(方法论第 4 条) :第一版读 Windows
GetProcessMemoryInfo忘了给cb字段赋sizeof,API 静默返回 FALSE、全零 ,我打印成 "RSS 不可读"。数据是 0,我读成了"没有数据"------这正是第 12 篇讲过的静默失败,这次轮到我自己。
2.6 C++ 侧:只有 C++ 拿得到的内存优化
cpp
// 未编译:本机无 OpenCV C++ SDK,按官方签名推演
// C++ 才有的两个杠杆:
// ① 自定义分配器 / 复用池:Mat 的引用计数 + 外部数据指针(cv::Mat 的
// 构造函数 cv::Mat(rows, cols, type, data, step) 可以零拷贝地"挂"在
// 你自己的缓冲区上,Python 侧做不到)
// ② 内存池 + scope 复用:函数内 static/成员 Mat,跨帧复用同一块
void detect(const cv::Mat& frame) {
static cv::Mat gray, blur; // ✅ 每帧复用,零分配
cv::cvtColor(frame, gray, cv::COLOR_BGR2GRAY);
cv::GaussianBlur(gray, blur, cv::Size(5, 5), 0);
// ❌ 反例:cv::Mat gray = ...; 每帧构造析构,走全局分配器
}
Python 侧的 dst= 是这一节的能力上限 ;挂载外部缓冲区、对象池这些手段,只有 C++ 侧拿得到(未编译,按签名推演)。
三、隐形拷贝的字节账与判定方法
3.1 五种写法,两种是视图(probe13_c[C1])
"这行代码拷贝了吗"不能靠看,要问 np.shares_memory:
| 写法 | shares_memory | nbytes | 判定 |
|---|---|---|---|
切片 img[100:900,100:900] |
True | 1920000 | 无拷贝(视图) |
np.asarray(roi) |
True | 1920000 | 无拷贝(视图) |
np.array(roi) 默认 copy=True |
False | 1920000 | 有拷贝 |
roi.astype(np.float32) |
False | 7680000 | 有拷贝(还翻 4 倍) |
cv2.cvtColor(roi, ...) |
False | 640000 | 有拷贝(返回新数组) |
np.array(x) 默认就拷贝 ------名字看起来像"转换",其实每次都在分配。np.asarray 才是零拷贝的那一个。这跟 C++ 侧 cv::Mat m2 = img 恰好方向相反(那是浅拷贝不复制数据)。
3.2 三组对照才能算准非连续的代价(probe13_c[C2])
img.T 是非连续视图,喂给 cvtColor:
| 输入 | 形状 | 连续 | cvtColor 中位 |
|---|---|---|---|
img |
(1080, 1920, 3) | 是 | 0.6946 ms |
img.T |
(1920, 1080, 3) | 否 | 8.8625 ms(+1176.0%) |
img.T 的连续副本 |
(1920, 1080, 3) | 是 | 0.7986 ms(+15.0%) |
只做前两组会得到 "+8.1679 ms 都是拷贝代价" ------错。第三组证明维度/形状变化本身只值 0.1041 ms,纯非连续增量是:
text
纯非连续增量 = +8.0638 ms (+1009.7%) <- 这才是「绑定内隐形拷贝」的净代价
方法论第 8 条 :我第一版只有两组 ,把 0.1041 ms 的维度因素算进了拷贝代价。差一组对照,结论就差在把"转置视图的内存布局"和"绑定内部连续化"混为一谈。 对照实验的组数,要覆盖你所有改变的变量------你改了形状,就得有同形状的对照。
3.3 但小尺寸上这个代价测不出来(probe13_a[A1])
同一件事扫 4 个尺寸 × 2 个算子,共 8 组:
text
绝对差全部落在 -0.0196 ms ~ +0.0309 ms;百分比区间 -14.6% ~ +11.7%
其中 |绝对差| >= 0.005 ms 的有 4/8 组 -> 正 2 / 负 2
噪声护栏判读:正负交替,无一致方向 -> 未测出系统性拷贝代价
8 组里正负交替,方向不一致 → 在 100~1600 这些尺寸、cvtColor/GaussianBlur 这两个算子上,测不出系统性的非连续惩罚。 这跟 §3.2 的 1009.7% 不矛盾吗?矛盾。解释是负载不同 :§3.2 是 1080p 全图转置(一次 207 万像素的连续化),§3.1 的 ROI 是 img[50:50+sz, 50:50+sz] 这种同宽行距 的切片,绑定侧的处理路径不同。两个结论都带各自的前提写,不合并成一条"规律"。
方法论第 9 条(单点当边界) :这一节我犯了两次。第一版只测了 800×800 一个点就写下"拷贝代价被噪声淹没";重跑时又冒出一个 +184.8% ------绝对差只有 0.0005 ms ,微小负载上的噪声被百分比放大。加了 0.005 ms 绝对量护栏 、扫满 4 尺寸之后,才敢写上面这段。百分比必须配绝对量,否则最小的那个数字永远最吓人。
3.4 id() 为什么不能当证据(probe13_c[C3])
判定"有没有复用对象"的正确姿势是先持有引用:
text
持有 7 个引用(含输入)后:id 去重 7 / 共 7
判定 = 每步都新建对象,无复用(持有引用后 id 才可信)
上一版未持引用,得到 5/7 -> 属于 id 地址被 GC 回收复用的假象,结论已推翻。
Python 回收对象后地址会被下一个对象复用 ,所以"两次 id() 相等"可能是"同一对象",也可能是"同一地址先后住过两个对象"。objgraph / 强引用持有 / sys.getrefcount 都比裸 id() 可靠。
3.5 C++ 侧的隐形拷贝三形态
cpp
// 未编译:本机无 OpenCV C++ SDK,按官方签名推演
// 形态 1:按值传参 ------ 如果参数是 std::vector<Point> 而你传临时量,整块复制
void draw(std::vector<cv::Point> pts); // ❌ 按值:每次调用复制
void draw(const std::vector<cv::Point>& pts); // ✅ 常引用
// 形态 2:auto 推导丢掉引用与顶层 const
auto m = someFunctionReturningMatByValue(); // 拿到的是副本(浅拷贝,共享像素)
// 若要意图是"不拷",用 auto&& / const auto&(视函数返回值类别而定)
// 形态 3:std::vector 中转 ------ Mat -> vector<uchar> -> Mat 至少多两轮复制
std::vector<uchar> buf(mat.total()*mat.elemSize());
memcpy(buf.data(), mat.data, buf.size()); // ❌ 中转即复制
// ✅ 直接用 mat.ptr / mat.data 指针访问
这三形态本机无法实测 (无 C++ SDK),列为按官方签名推演。
四、并行:setNumThreads 与超订
4.1 扫描 n = 0/1/2/4/8/16/18/100(probe13_d[D1])
负载 A:1080p GaussianBlur(31,31);负载 B:20 次 resize 小批量:
| n | 实得线程 | 大负载中位 | p95 | 小批量中位 |
|---|---|---|---|---|
| 0 | 1 | 19.9527 ms | 26.8130 | 3.3190 ms |
| 1 | 1 | 19.6227 ms | 27.7728 | 3.2639 ms |
| 2 | 2 | 19.7359 ms | 27.4738 | 2.8922 ms |
| 4 | 4 | 11.1340 ms | 18.2793 | 2.7880 ms |
| 8 | 8 | 6.9463 ms | 11.7908 | 3.5563 ms |
| 16 | 16 | 5.9096 ms | 9.2576 | 3.8439 ms |
| 18 | 18 | 6.0519 ms | 9.7112 | 3.4987 ms |
| 100 | 100 | 5.7339 ms | 7.8142 | 2.0232 ms |
三个读点:
n=0实得 1 线程------与第 11 篇一致:0 是"禁用内部并行",不是"恢复默认"。- 大负载从 19.6 ms 到 5.7 ms,约 3.4x ------比第 11 篇当时测的 2.21x 高,因为负载不同(本探针是单次 31×31 大核模糊,可并行度更高)。线程收益是负载的函数,不是库的常数。
- 小批量那列完全不单调(3.26 → 2.79 → 3.56 → 3.84 → 2.02)------每次调用太便宜,线程池调度抖动盖过收益。
4.2 两个语义事实(probe13_d[D2])
text
setNumThreads(0) -> getNumThreads()=1 实测:0 未原样保留,回落到 1
setNumThreads(1) -> getNumThreads()=1
setNumThreads(999) -> getNumThreads()=999 实测:未被钳制到 18,原样接受
本机默认 -> 18
0 → 1:承第 11 篇,语义 5.0.0 未变。999 → 999(新事实) :不做核数钳制 。你以为"设超了系统会拉回来",getNumThreads()会原样回显 999。实际生效多少线程不体现在这个回显里------这解释了为什么"设个大数总没错"是错觉。
这里我第一版探针的标签与数据矛盾 (方法论第 5 条):我在打印语里写死
超核数被钳制,而同一行数据明晃晃是 999。结论行硬编码,是本篇最高频的翻车方式------后面方法论里还有两次同型。
4.3 找最优线程数:16 不是 18(probe13_d[D3])
| n | 1 | 2 | 4 | 8 | 12 | 16 | 18 |
|---|---|---|---|---|---|---|---|
| 中位 ms | 19.9402 | 21.2647 | 12.4464 | 8.0143 | 6.5049 | 5.7278 | 5.8235 |
本负载最优 n=16 ,18 反而略差(5.8235)。n=2 还比 1 慢(21.2647 vs 19.9402)------第 11 篇也见过这个反常点,当时判定是线程池预热噪声,本篇复现了同样形状:单点反常不当结论,扫全了再说。
4.4 同一扫描,8 线程比 18 快(probe13_f[F5])
30 组参数扫描(§6.1 的那个负载),只变线程数:
text
threads=8 全扫描中位 28.220 ms
threads=18 全扫描中位 30.349 ms
比值 1.08x -> 8 线程更快
同一台机器、同一个脚本,"最优线程数"从 16(§4.3)跑到 8(本节)。 结论只有一个:
线程数没有全局真理,必须按你的实际负载实测。 第 11 篇的
默认 18是初始值 ,不是推荐值。扫描 5~7 个点、各跑 30 次取中位,成本不到一分钟------比上线后再查"为什么有时快有时慢"便宜得多。
4.5 并行后端实况:wheel 里带的是 IPP(probe13_e[E7])
规划里要求核验"pip wheel 是否含 IPP/TBB 后端",实测 cv2.getBuildInformation():
text
3rdparty dependencies: ... ipphal ippiw ippicv
Parallel framework: Concurrency
Intel IPP: 2026.0.0 [2026.0.0]
OpenCL: YES (NVD3D11)
- 有 IPP 2026.0.0 (不是 TBB/OpenMP 作为 Parallel framework,报告值是
Concurrency)。 - OpenCL: YES (NVD3D11) ------headless wheel 也带 OpenCL 运行时路径,但本篇没有测 GPU 路径,不给任何 GPU 数字。
- 没有 TBB 字样 ------"装了 wheel 就有 TBB 加速"这句话在这份 wheel 上不成立。
4.6 C++ 侧:并行只有 C++ 侧拿得到的控制粒度
cpp
// 未编译:本机无 OpenCV C++ SDK,按官方签名推演
// ① parallel_for_:把"一帧内"的并行写在自己代码里,而不是指望算子内部
cv::parallel_for_(cv::Range(0, rows), [&](const cv::Range& r) {
for (int y = r.start; y < r.end; ++y) { /* 行级并行 */ }
});
// ② setNumThreads 是全局的:C++ 侧还能用 cv::setUseOptimized / 局部
// cv::theRNG() 等控制粒度;Python 侧只有全局 setNumThreads 一个旋钮
cv::setNumThreads(8); // 全局生效,影响进程内所有算子
业务线程 × OpenCV 内部线程的超订 :你在 Python 起 4 个 worker,每个 worker 里的 cv2 默认开 18 内部线程 → 72 个线程抢 18 个核 (第 11 篇的公式)。正确姿势是在进程启动时 就把 setNumThreads 定死(如 max(1, os.cpu_count() // worker数)),而不是在 worker 里各设各的。
五、计时不准的经典原因
5.1 头号错误:你测的是系统运行时长(probe13_d[D5])
最常见的 benchmark 写法是记一堆时间戳然后取中位------如果忘了做差:
text
20 次原始 perf_counter 中位 = 23195140.4 ms <- 这是系统运行时长,不是单次耗时
加 np.diff 后中位 = 5.8214 ms <- 真实单次耗时
误差倍数 = 3984461 倍
23195140 ms ≈ 6.4 小时 ,这是机器开机时长级别的数字。它不会报错、不会异常,就是一个看起来很稳的数。
方法论第 6 条 :我第一版
probe13_d整个表格都是这个错------四个线程数下"耗时"几乎相等(都在 23085278~23099134 之间),我差点按"线程数完全无影响"写结论。方向检查救了我:线程数从 0 到 100 耗时差 0.03%,这不可能是真结论。 现在我的纪律是:先问"这个实验理想情况下应该出什么趋势",趋势不对,先查计时代码,再查算子。
一行修好:
python
t = [time.perf_counter() for _ in range(n)] # ❌ 只有时间戳
# ✅ 每次:t0=time.perf_counter(); fn(); d=(time.perf_counter()-t0)
# 或者:t.append(perf_counter()); ... 然后 np.diff(t) 再统计
5.2 计时器选 perf_counter(`probe13_eE2``)
连续两次调用同一计时器,差为 0 的比例:
| 计时器 | 差为 0 的比例 | 非零最小粒度 |
|---|---|---|
time.time |
71.2% | 238.4 ns |
perf_counter |
44.9% | 100.0 ns |
process_time |
100.0% | (全部为 0,粒度 15.625 ms) |
process_time 七千次里没有一次能和上一次区分 ------它量的是 CPU 时间、Windows 上粒度是 15.625 ms,微基准用它等于全测零。perf_counter 粒度最好,微基准用 perf_counter。
5.3 不预热,首调用贵 25%(probe13_e[E1])
text
8 轮首调用中位 = 0.6164 ms 稳态中位 = 0.4911 ms
首调用 / 稳态 = 1.25x -> 预热后才好计时
每次换尺寸/换算子,首次调用走分配 + 内核选择路径,比稳态贵 25% 。纪律:先跑 10~50 次不计时,再开始统计 (本篇所有探针都是 for _ in range(10): fn() 才计时)。
5.4 只测最后一段,漏掉 93.1%(probe13_e[E3])
text
全流水线单次 10.4573 ms
只测最后一段(dilate) 0.7251 ms
漏掉 93.1% 的耗时 -> 按局部耗时估总耗时会严重偏低
"我这个 dilate 只要 0.7ms,肯定来得及"------你的链路是 10.5ms 。估预算必须掐整条链 ,或者逐段都掐然后求和。
5.5 "结果被编译器优化掉"------Python 侧不成立(probe13_e[E4])
规划里这条是为 C++ 准备的(-O2 删掉未使用结果)。Python 侧实测:
text
丢弃返回值 0.9511 ms / 保留返回值 0.9269 ms / 差 -0.0242 ms (-2.5%)
-> 差异为噪声级别:Python 不会像 C++ 那样优化掉未使用的结果,两者等价
±2.5% 是噪声,不是系统性差异。 但这个坑在 C++ 侧是真的:
cpp
// 未编译:本机无 OpenCV C++ SDK,按官方签名推演
cv::GaussianBlur(src, dst, size); // ❌ dst 未使用,-O2 可能把整段删掉
cv::GaussianBlur(src, dst, size);
use(dst); // ✅ 让结果被"用到"
// 或者 benchmark 里 volatile / DoNotOptimize 一类栅栏
同一条"计时陷阱",在 Python 和 C++ 的成立性相反------写 benchmark 前先问自己在哪一侧。
5.6 OpenCV 不是惰性求值(probe13_e[E5])
text
调用即耗时 0.9997 ms / 之后读取单点 0.01380 ms -> 计算在调用时已完成
r.flags.writeable=True shares_memory=False -> 结果为独立缓冲区
计算发生在调用时刻 ,读结果只花 0.0138 ms。所以"把读结果放在计时外/内"没有差别 ------规划里担心的"汇总处才耗时"在这份 wheel 上不存在(PyTorch/TF 的计算图那种惰性是另一回事,别混)。
5.7 报 fps 必须写统计口径(probe13_e[E6])
同一组含长尾的样本:
text
样本 = [0.039, 0.094, 0.541, 1.513, 0.037, 0.063]
均值 0.381 ms 中位 0.078 ms p95 1.270 ms 最大 1.513 ms
均值/中位 = 4.86x -> 长尾把均值拉高,报 fps 时口径必须写明
均值是中位的 4.86 倍 ------同一份数据,报"平均 0.381ms(2624 fps)"和"中位 0.078ms(12820 fps)"差 5 倍。产线只关心尾部:p95 和最大值决定你会不会偶尔丢帧(第 11 篇的节拍预算就是给 p95 用的)。
六、配置驱动与全参数批量跑图
6.1 硬编码 1 组 vs 一次扫 30 组(probe13_f[F1][F2])
阈值 5 档 × 核尺寸 3 档 × 形态学迭代 2 档 = 30 组:
text
硬编码 1 组:thresh=127 ksize=3 iters=1 -> 1.3563 ms,改参数需改代码
配置驱动:... -> 组合 30 组(一次跑完)
扫描耗时 34.749 ms / 30 组 = 均 1.1583 ms 每组
30 组的结果按"白占比"排序后,跨度 76.07 个百分点 (0.00% → 76.07%)------同一算法、不同参数,输出差这么多,手工改参数试 30 次是不可能的,一次批量跑完才能横向比。
6.2 参数外置到 JSON,改配置不改代码(probe13_f[F3])
json
{"thresholds": [64, 96, 127, 160, 192], "ksizes": [3, 5, 7], "iters": [1, 2]}
落盘 → 回读一致性 True。工程意义不是省几行代码,是:现场改参数不需要重新发布,且"这次跑的是哪组参数"有文件可查------产线上"昨天还好好的"有 10% 是参数被人改过而你不知道。
6.3 结果可复现:3 次完全一致(probe13_f[F4])
text
3 次扫描结果完全一致 = True(确定性算法,耗时可变但输出不变)
耗时会抖(见 §5.7),输出不会 ------阈值和形态学是确定性的。"性能回归"要盯的是耗时分布 ,"算法回归"盯的是输出本身,两个监控不要混在一个指标里。
6.4 把前面所有纪律拼成一次可复现扫描(probe13_f[F5])
text
预热: 首轮丢弃 <- §5.3
计时器: time.perf_counter(E2 结论) <- §5.2
输出缓冲: dst= 预分配复用(B1 结论:中位 4.0x) <- §2.3
线程数: 固定 18 <- §4
四条纪律各自只值 0.x 倍到 4 倍,合起来才构成"这个数字可以写进报告"的资格。
6.5 错误处理三件套:断言、异常、日志
python
def run_pipeline(img: np.ndarray, cfg: dict) -> dict:
# 1) 断言:程序员错误(形状、dtype)------开发期炸,别带上线
assert img.ndim == 3 and img.dtype == np.uint8, f"expect HxWx3 uint8, got {img.shape} {img.dtype}"
# 2) 异常:环境/输入错误(文件不存在、尺寸不合)------调用方能 catch
if img.shape[0] < 64 or img.shape[1] < 64:
raise ValueError(f"image too small: {img.shape}")
# 3) 日志:现场可查(参数、耗时、线程数)------出事能回溯
logger.info("cfg=%s threads=%d", cfg, cv2.getNumThreads())
...
三件套的分工:断言拦"我写错了",异常拦"现场给错了",日志留给"事后查" 。缺任何一样,第 12 篇那种"静默回退"就会重演(probe13_b[B5] 我自己就栽了一次)。
6.6 模块接口:输入 + 配置 → 输出结构体
python
@dataclass(frozen=True)
class PipelineConfig:
thresh: int = 127
ksize: int = 3
iters: int = 1
threads: int = 0 # 0 = 不动全局设置
@dataclass
class PipelineResult:
mask: np.ndarray
white_pct: float
elapsed_ms: float
def run(img: np.ndarray, cfg: PipelineConfig) -> PipelineResult:
...
为什么输出要包结构体而不是裸数组 :本篇 §5.7 证明"一个耗时数字没有口径就没有意义"。elapsed_ms 跟着结果一起返回,报告和数据永不分离------这是把第 11、13 两篇的教训固化成接口。
6.7 TickRecorder 在 Python 侧不存在(probe13_e[E7])
规划要求核验 5.0.0 是否补齐了 C++ 侧的 TickRecorder:
text
hasattr(cv2,'TickRecorder') = False
hasattr(cv2,'TickMeter') = True
hasattr(cv2,'Tick') = False
没补齐。 cv2.TickMeter 在,TickRecorder 不在------与第 11 篇结论一致,5.0.0 Python 绑定仍然没有 这个类。照抄 C++ 教程写 cv2.TickRecorder() 必然 AttributeError。本篇全部计时用 time.perf_counter(),与框架无关、可移植。
避坑指南
坑 1:以为所有算子都支持 dst=
python
# ❌ 错的:cv2.Canny(buf, 50, 150, dst=buf)
# TypeError: Canny() got an unexpected keyword argument 'dst'
# ✅ 对的:实测逐个验证,5/6 支持;Canny 只能接新分配的返回值
edges = cv2.Canny(buf, 50, 150)
坑 2:每帧新建输出缓冲
python
# ❌ 错的:400 帧实测中位 0.6614 ms,且每帧都在分配(probe13_b)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# ✅ 对的:预分配 + dst=,中位 0.1637 ms,4.042x
cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY, dst=gray_buf)
坑 3:用 src.copy() 而不是预分配 np.copyto
python
# ❌ 错的:中位 0.4235 ms
dup = src.copy()
# ✅ 对的:np.copyto 预分配,中位 0.0599 ms,7.070x
np.copyto(dup_buf, src)
坑 4:把视图当拷贝(或反过来)
python
# ❌ 错的:np.array(roi) 默认就拷贝,还要自己解释为什么慢
# roi.astype(np.float32) 直接把 nbytes 翻 4 倍
# ✅ 对的:要零拷贝用 np.asarray / 切片;要拷贝就显式 .copy() 并知道自己在分配
# 判定永远问 np.shares_memory(arr, base)(probe13_c[C1])
坑 5:把非连续 ROI 直接喂重算子
python
# ❌ 错的:cv2.cvtColor(img.T, ...) 1080p 实测 8.8625 ms vs 连续 0.6946 ms
# ✅ 对的:喂之前 ascontiguousarray,多花 0.7986 ms(同形状对照)换 12 倍收益
# 且:小尺寸(<=1600²)上这个代价可能测不出来,别拿小样本下"无所谓"的结论
坑 6:把 setNumThreads(0) 当恢复默认
python
# ❌ 错的:setNumThreads(0) 之后以为"回到默认 18"
# ✅ 实测:0 -> getNumThreads()=1,是禁用并行;恢复没有直接 API,记原值自己还原
坑 7:以为 999 会被钳制到核数
python
# ❌ 错的:setNumThreads(999),反正系统会拉回 18
# ✅ 实测:getNumThreads()=999,原样回显不钳制;实际生效线程数不体现在回显里
坑 8:业务线程 × cv2 内部 18 线程超订
python
# ❌ 错的:4 个 worker 各自跑,默认各开 18 内部线程 -> 72 线程抢 18 核
# ✅ 对的:进程启动时统一 setNumThreads(max(1, os.cpu_count() // workers))
# 先 getNumThreads() 打日志,再开 worker
坑 9:线程数不按负载实测
python
# ❌ 错的:"默认 18 就用 18"
# ✅ 实测:大核模糊最优 16;30 组扫描负载 8 反而比 18 快 1.08x(probe13_d / probe13_f)
# 扫 5~7 个点 x 30 次取中位,一分钟的事
坑 10:漏 np.diff,把绝对时间戳当耗时
python
# ❌ 错的:t.append(perf_counter()); fn() ... 然后 np.median(t)
# 实测 23195140.4 ms vs 真实 5.8214 ms,误差 3984461 倍(probe13_d[D5])
# ✅ 对的:t0=perf_counter(); fn(); d=perf_counter()-t0 或统计前 np.diff(t)
# 且做方向检查:如果自变量变化但因变量不动,先查计时再查算子
坑 11:用 time.time / process_time 做微基准
python
# ❌ 错的:process_time 连续差 100% 为 0(粒度 15.625 ms);time.time 71.2% 为 0
# ✅ 对的:time.perf_counter,44.9% 为 0、最小粒度 100 ns(probe13_e[E2])
坑 12:不预热就计时
python
# ❌ 错的:换尺寸后第一次调用直接进统计 -> 首调用是稳态的 1.25x(probe13_e[E1])
# ✅ 对的:先 for _ in range(10): fn() 丢弃,再开始收集样本
坑 13:只测最后一段估总耗时
python
# ❌ 错的:"dilate 只要 0.7251 ms" -> 全链实测 10.4573 ms,漏 93.1%(probe13_e[E3])
# ✅ 对的:掐整条链,或逐段掐了求和;节拍预算按 p95 算(承第 11 篇)
坑 14:报 fps 不写统计口径
python
# ❌ 错的:"平均 0.381 ms(2624 fps)" ------ 同组数据中位是 0.078 ms,差 4.86x
# ✅ 对的:中位 + p95 + 最大值一起报,并写明样本数与预热策略(probe13_e[E6])
坑 15:持有 300 帧中间结果不放
python
# ❌ 错的:frames.append(gray) 攒着"待会儿用" -> 实测增量 594.2 MiB(理论 593.3)
# ✅ 对的:处理完就释放引用;确需缓存,按预算算清 frames * nbytes 并写进文档
坑 16:把 id() 去重当对象复用证据
python
# ❌ 错的:未持引用时 id 去重 5/7,写下"存在对象复用"
# ✅ 对的:先持有全部引用 -> 7/7,每步都新建;GC 会复用地址,裸 id() 不可信
坑 17:参数硬编码,调参改代码
python
# ❌ 错的:cv2.threshold(g, 127, ...) 写死,现场调参 = 改代码 = 重新发布
# ✅ 对的:阈值/核尺寸/迭代次数外置 JSON;一次跑 30 组横比,白占比跨度 76.07 个百分点
坑 18:把某个线程数当全局真理
python
# ❌ 错的:"最优就是 16"(那是大核模糊的结论)/ "就是 18"(那是默认值)
# ✅ 对的:最优随负载漂移,8 与 16 都出现过;把扫描动作做成脚本,换负载就重扫
优秀实践
python
# pipeline.py ------ 本篇 6 条纪律的落地形态
import json, time, cv2, numpy as np
from dataclasses import dataclass
@dataclass(frozen=True)
class PipelineConfig:
thresh: int = 127
ksize: int = 3
iters: int = 1
def load_cfg(path: str) -> PipelineConfig:
with open(path, encoding="utf-8") as f:
return PipelineConfig(**json.load(f)) # 改配置不改代码(§6.2)
def run(frame: np.ndarray, cfg: PipelineConfig,
gray_buf: np.ndarray, mask_buf: np.ndarray) -> tuple:
# 1. 断言程序员错误
assert frame.dtype == np.uint8 and frame.ndim == 3
# 2. 预分配复用:5/6 算子支持 dst=(§2.1)
cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY, dst=gray_buf)
cv2.GaussianBlur(gray_buf, (5, 5), 0, dst=gray_buf)
edges = cv2.Canny(gray_buf, 50, 150) # Canny 必然新分配
k = np.ones((cfg.ksize, cfg.ksize), np.uint8)
cv2.dilate(edges, k, dst=edges)
cv2.erode(edges, k, dst=edges)
cv2.threshold(edges, cfg.thresh, 255, cv2.THRESH_BINARY, dst=mask_buf)
return mask_buf, float((mask_buf > 0).mean() * 100)
def benchmark(fn, warmup=10, rounds=200):
for _ in range(warmup): fn() # §5.3 预热
t = []
for _ in range(rounds): # §5.2 perf_counter
t0 = time.perf_counter(); fn(); t.append(time.perf_counter() - t0)
d = np.diff(np.array(t)) * 1e3 # §5.1 必须做差
return dict(median=float(np.median(d)),
p95=float(np.percentile(d, 95)),
max=float(np.max(d))) # §5.7 口径齐全
# 进程启动时(不是每个 worker 里)固定并行度(§4.6)
cv2.setNumThreads(8)
六条纪律一句话版:预分配(4.042x)/ 逐个验 dst=(5/6)/ 做差(3984461x)/ 用 perf_counter(100ns)/ 预热(1.25x)/ 固定线程数(1.08x 可差)。
可改进之处
- 本篇 10 处自我推翻(7 处判读与数据矛盾 + 2 处单点测试被当成边界 + 1 处签名/边界凭印象记错)全部记在《方法论》,但有两类还没根治 :①结论行硬编码 ------A1 旧区间、E2 旧百分比、D2「已钳制」标签,三次都是"数字写在字符串里而不是从数据算出来"。已改的三个探针(结论由当场数据计算)是对的方向,后续探针的结论行一律禁止写死数字。
- 探针缺一组 :§3.3 与 §3.2 的结论前提不同但没在同一批实验里对齐 (全图转置 vs 小 ROI 切片)。要合并成一条规律,得补"同尺寸、两种非连续来源"的对照。本篇不做,记为遗留。
setNumThreads(999)只验证了回显,没验证实际生效线程数(Windows 上要另开工具数线程才知)。「不钳制」是回显事实,不是调度事实。- OpenCL 路径完全没测 :
getBuildInformation报 YES (NVD3D11),但本篇所有数字都是 CPU 路径。GPU/UMat 是独立课题,留待后续。 - C++ 侧全是推演 :4 段 C++ 逐块标了
未编译。哪些优化"只有 C++ 拿得到"(挂外部缓冲、parallel_for_、对象池)目前只有签名级依据,等有 SDK 的机器再补数字。 - 每帧耗时分布只报了中位/p95/最大,没做跨日稳定性(温度、后台进程)。产线要的"连续跑 8 小时 p95 不漂"是另一个量级的实验。
工程实践
- 参数进 JSON,版本进 Git:现场只发配置不发代码(§6.2 实测回读一致)。改动配置文件必须留 diff------产线"昨天还好好的"经常就是参数被人动了。
- 监控看三个数,不看一个 :
median / p95 / max。中位说明"平时多快",p95 说明"会不会偶尔丢帧"(第 11 篇节拍预算),max 说明"最坏能多坏"。 - 线程数是启动期配置,不是运行期逻辑 :进程入口
setNumThreads一次,然后getNumThreads()写进启动日志。worker 内部不许再动。 - 分配预算写进文档 :像第 11 篇给 25fps 算 40ms 预算一样,给内存算预算------
并发帧数 × nbytes ≤ 上限。§2.5 的 594.2 MiB 是 300 帧的账,你的队列深度是多少? - benchmark 脚本与算法代码分离 :
benchmark()是可复用资产(本篇给了骨架)。换负载重跑setNumThreads扫描、换计时口径重跑np.diff检查,都应该是改一个参数的事。 - 探针结论行禁止硬编码 :
print("结论:...")里的数字必须来自当次计算的变量。本篇 3 次翻车全在这一行上。 - 静默失败要设门禁 :Windows API 返回 FALSE 不抛异常(§2.5 我的教训);OpenCV 参数回退不报错(第 11 篇 fourcc 的教训)。凡是"结果是 0/None/默认值"的路径,先问一句是不是调用失败了。
算子选型速查表
| 场景 | 首选 | 观察点 | 本篇依据 |
|---|---|---|---|
| 固定形状、逐帧调用 | 预分配 + dst= |
id 不变 = 零分配 |
4.042x / 7.070x |
| 需要独立副本 | .copy() / np.copyto |
shares_memory 转 False |
C1 判定表 |
| 零拷贝取视图 | 切片 / np.asarray |
shares_memory=True |
C1 判定表 |
| 连续化非连续输入 | ascontiguousarray |
与同形状对照比 | C2 三组对照 |
| 单算子微基准 | perf_counter + 预热 + np.diff |
方向检查 | D5 / E1 / E2 |
| 链路耗时预算 | 逐段或整链 p95 | 口径写进报告 | E3 / E6 |
| 线程数 | 按负载扫描 5~7 点 | 扫描动作脚本化 | D3 / F5 |
| 参数调优 | 配置外置 + 全参数扫描 | 一次跑全组合 | F1~F4 |
| C++ 侧零拷贝 | Mat 外部数据指针 / parallel_for_ |
未编译 | §2.6 / §4.6 |
方法论:这一篇我被自己的数据打脸 10 次(7 处判读与数据矛盾、2 处单点测试被当成边界、1 处签名/边界凭印象记错)
这一篇技术内容不难,难在我的探针错了 10 处 。按错误类型分三类:① 判读与数据矛盾 7 处、② 单点测试被当成边界 2 处、③ 签名/边界凭印象记错 1 处。逐条列出:
① 判读与数据矛盾(7 处)------数据摆在那,我写下的是另一回事:
- A1 结论行硬编码旧区间 :探针数据已经换成新跑的一轮,
print里的-48.2%~+11.5%还是上一轮的字符串。结论和数据不在同一个变量里,就一定会漂。 - A4 漏传
dst=的假阳性 :改测试时把 Canny 那行的dst=删了,于是"6/6 支持 dst="堂而皇之打印出来------测试没测,结果恒为"支持"。逐个显式传才拿到真实 5/6。 - C3
id()地址复用误判 :未持引用时去重 5/7,写下"存在对象复用";持引用后 7/7,结论反了。GC 回收的地址会被复用,裸id()不是身份证明。 - B5 全零被写成「RSS 不可读」 :读 Windows
GetProcessMemoryInfo忘了赋cb=sizeof,API 静默返回 FALSE、结构体全零;我把"数据是 0"读成了"没有数据"。静默失败的 0 和真 0 长得一模一样。 - D2 标签「超核数被钳制」与数据 999 打架 :同一行里标签写死"钳制",数据明晃晃 999。标签是我预期的复读,不是数据的转述。
- D 表漏
np.diff:把perf_counter()绝对时间戳取中位,整张线程扫描表"耗时"全是 2308 万 ms 量级、彼此差 0.03%。差点按"线程数无影响"写结论------是"趋势不该是平的"这个方向检查救了我。 - E2 结论行硬编码零比例 :
73.1%/51.1%是手写的,当轮实测是别的值。与第 1 条同型:结论不从数据算,就必然与数据脱钩。
② 单点测试被当成边界(2 处)------一个样本推不出规则:
- A1 单点 + 百分比放大 :先只测 800×800 就写"拷贝代价被噪声淹没"(单点当边界);重跑又冒出 +184.8% ,绝对差仅 0.0005 ms ------微小负载的噪声被百分比放大成吓人数字。对策:加 0.005 ms 绝对量护栏 + 扫满 4 尺寸 × 2 算子,百分比永远和绝对量一起报。
- C2 只有两组对照 :
imgvsimg.T两组算出 "+8.1679 ms 全是拷贝代价";补第三组同形状连续副本后,维度因素值 0.1041 ms,纯非连续增量是 +8.0638 ms,而且归因完全不同 。对策:对照组必须覆盖你改动的每一个变量------改了形状,就要有同形状的对照。
③ 签名/边界凭印象记错(1 处)------不实跑就永远不暴露:
- 按印象写下
cv2.Canny(buf,50,150,dst=buf):直接TypeError。这类错误最危险,因为它"看起来完全合理" ------六个算子里五个支持,凭什么 Canny 不支持?如果不真的跑一遍,我会带着这个印象写完 900 行。对策:任何"支不支持 X"的断言,探针里必须真的传一次 X。
三类的共同解法已经固化成两条纪律 (见《可改进之处》《工程实践》):结论行禁止硬编码数字 (对付 ①:7 处里 3 处是这一条)、单点样本禁止当边界 + 百分比必须配绝对量 (对付 ②)。第 ③ 类没有捷径,只能实跑。
还有一条元教训 :这 10 处里有 9 处发生在**"我怎么测"**而不是"我怎么写"。第 11 篇我错 4 次、第 12 篇错 3 次,本篇 10 次------随着文章从"讲 API"走向"讲测量",测量本身的 bug 占比越来越高 。现在的顺序是:先写下"理想情况下这个实验应该出什么趋势",再写探针;跑出来趋势不对,先查探针,再查算子。
全系列算法地图回顾(第 1~13 篇)
| 篇 | 一句话定位 | 关键词 |
|---|---|---|
| 01 | 我用了两年 OpenCV 只调用过 3 个函数 | 入门心智模型 |
| 02 | cvMat 内存模型 | 引用计数 / 浅拷贝(C++ 侧一切拷贝问题的源头) |
| 03 | 颜色空间与通道顺序 | BGR / 通道 |
| 04 | 去噪与形态学 | 滤波 / 开闭运算 |
| 05 | 阈值分割 | threshold 族 |
| 06 | 几何变换 | resize / warp |
| 07 | 边缘与轮廓 | Canny / findContours |
| 08 | 几何测量 | 像素到物理量 |
| 09 | 模板匹配与特征匹配 | matchTemplate / ORB |
| 10 | 相机标定与三维 | 标定 / PnP / 三角化 |
| 11 | 视频流与实时处理 | 丢帧 / 节拍 / 线程 |
| 12 | DNN 与目标检测 | 推理 / 后处理 |
| 13(本篇) | 工程化与性能 | 内存账 / dst= / 线程数 / 计时 / 配置外置 |
02 是 C++ 拷贝语义的地基,11 是实时性治理,13 把三者接起来:内存账用的就是 02 的引用计数语义,计时口径服务的是 11 的节拍预算,配置外置和错误三件套收尾整个系列的工程面。剩 14~18 篇按规划走 photo/ml/stitching、GAPI-C++ 主线、contrib 路线与收束。
本期互动
这一篇最扎心的不是任何算子,而是:10 处自我推翻里 9 处发生在"我怎么测",不是"我怎么写"。
漏一个 np.diff,误差 3984461 倍;忘了 dst= 参数,探针告诉你"6/6 支持";结论行写死字符串,跑出来的数据和写的不是一回事------这些全都"跑通了",而且全都错了。 10 处按类型分:7 处判读与数据矛盾、2 处单点测试被当成边界、1 处签名/边界凭印象记错。
如果你也在做性能相关的事,评论区想收集两类翻车:
- **"数字跑出来了但方向不对"**的:比如你调参怎么调都没变化------我怀疑一半是计时口径,一半是单点样本
- **"以为系统帮我兜底了"**的:
setNumThreads(999)不钳制这件事,你们那边有没有别的表现?
评论区挑几个有代表性的展开聊。
💬 评论区聊聊
你最想在后续哪块看深度?
- A. photo / ml / stitching 三件套 (规划第 14 篇):去噪增强、传统 SVM 分类、全景拼接。核心判断题是------样本量多少以内,传统特征 + SVM 才是正确答案?
- B. 并行框架那条 C++ 主线 (规划第 15 篇,已探针实证 Python 侧
cv2.gapi不存在):GAPI /parallel_for_/ 自定义分配器这些"只有 C++ 拿得到"的东西,本篇只给了推演,想看带数字的版本。 - C. 测量与基准的系统方法 :本篇方法论 10 处全是测量翻车。可以专门写一篇怎么设计一个不会自己骗自己的 benchmark------基线对齐、方向预判、绝对量护栏、结论不硬编码。
留言告诉我选哪个,以及你有没有"跑通了但结论是错的"的经历------能贴出探针或统计代码的话,我会在评论区一起看是三类里的哪一类。