看到这个标题——opencv mat 统计小于0的个数——你可能觉得这就是个遍历计数的小问题。我一开始也这么认为结果真上手的时候被 Mat 的类型、通道数、连续性轮番招呼了一遍。OpenCV 的 Mat 不是一个简单的一维数组它背后有内存布局、数据类型、通道等一堆概念任何一个理解错了统计出来的数都可能离谱。这个需求其实很常见。举几个我碰过的场景深度传感器输出的深度图里无效点经常用负值标记统计负值个数就是评估一帧深度图像质量的快捷方式两张图像做差分后残差矩阵里的负值代表某一个方向的变化量统计它能判断场景中是否有遮挡或光照突变训练回归模型做图像重建时预测值与真值相减得到误差图负误差的比例会影响后续的调参策略。在这些场景里一个可靠的“负值计数”往往只是第一步但如果不把它写对后面的逻辑全都白搭。这篇文章会把这个问题拆开聊先从 Mat 的内存结构讲起再给四套不同侧重点的实现代码最后整理我在实操中踩过的坑和排查思路。不管你是刚接触 OpenCV 的初学者还是写了多年 C 但一碰到 Mat 就头疼的老手都能在里头找到一段能直接拿去用的代码。1. 需求拆解为什么一个计数问题能写出这么多花样1.1 实际场景决定统计口径很多人看到“统计小于0的个数”第一反应就是“数呗”。但真正做工程时先要搞清楚一件事你数的到底是“像素”还是“通道值”比如一张 CV_32FC3 的三通道浮点图一个像素位置上有三个 float。如果按“像素数”统计那一个像素只要有任意一个通道小于0要不要算一次如果三个通道都小于0是算一次还是算三次如果我关心的是第二个通道比如某个算法输出的深度分量那就只该统计那个通道。我见过不少同事直接把代码写成m.atfloat(r, c) 0然后对着结果百思不得其解。原因很简单atfloat对一个三通道 Mat 来说取到的只是第一个通道的值统计结果天然少了一大半。所以在写任何代码之前先问自己三个问题这个 Mat 是什么类型有没有符号它是单通道还是多通道我要统计的“一个数”是像素级口径还是通道值级口径这三个问题不解决后面那些花哨的优化全是空中楼阁。1.2 先认识 Mat 的内存布局OpenCV 的 Mat 由“头部”和“数据指针”组成。头部记录 rows、cols、channels、depth、step 这些元信息数据指针data指向真正存数值的内存区域。关键概念有三个depth每个数值的位深和格式比如 CV_8U、CV_16S、CV_32F、CV_64F。channels每个像素有几个通道常见 C1、C2、C3、C4。step每一行数据在内存里占用的字节数这个很重要。你可能会觉得Mat 不就是一个二维数组吗一行一行连续排着呗。大多数情况是这样但有一种例外从一个大矩阵里用 Rect 抠出来的 ROI。这种子矩阵的数据仍然是原矩阵内存的一部分行与行之间会隔着原矩阵的其他行数据所以在内存里并不连续。isContinuous()这个函数就是用来判断数据是否从头到尾连成一片的。如果它返回 true你可以把 Mat 当成一个一维数组来访问如果返回 false你还按一维数组访问读到的就不是你想要的顺序甚至会越界。另外注意total()的返回值。对单通道矩阵来说它等于 rows × cols对 CV_32FC3 这种三通道矩阵它返回的是 rows × cols × 3也就是说它把每个通道值都算作一个“元素”。很多人在指针遍历时用 total() 做循环上界发现结果“莫名其妙多算了两倍”就是因为没搞清楚这个细节。用生活里的例子来类比一个矩阵就像一摞外卖单正常情况下每张纸都紧挨着你可以从头翻到尾。但如果是从一沓大纸上裁下来的几张小条中间就可能隔着很多空白区直接顺着页码翻就会串行。Mat 的 isContinuous 就是告诉你面前这摞纸到底能不能从头一路翻到尾。1.3 几种主流方案的取舍统计小于0的个数我在不同项目里用过四类写法各有优劣方案优点缺点适用场景atT(r,c) 双重循环可读性最好新手一眼看懂慢尤其大图和视频流场景小矩阵、学习演示compare countNonZero内部有 SIMD 和线程优化代码短需要额外分配一个 mask 矩阵绝大多数工程场景首选手写指针遍历不额外分配内存可控性最强代码稍长要自己处理类型和连续性嵌入式、内存紧张环境parallel_for_ 并行分段能利用多核大矩阵收益明显实现最复杂线程调度有开销上百万元素的大矩阵、实时处理有人会问为什么不直接用threshold因为 threshold 的原生语义是“把满足条件的像素设为一个固定值”比如THRESH_BINARY_INV会把小于等于阈值的像素设为最大值它会把等于0的情况也算进去用来数“严格小于0”不准确。还有人想用minMaxLoc找最小值判断最小值小于0就知道“存在负数”但那只能回答“有没有”回答不了“有几个”。所以先别急着写循环选对工具往往比写对代码更省力。2. 动手前必须搞清楚的三个细节2.1 类型匹配无符号数据根本没有“负数”这是我最先踩的坑。某次我从上游模块拿到一个 Mat看起来是深度图实际类型是 CV_8U。CV_8U 是 8 位无符号整数取值范围 0 到 255它从定义上就“没有负数”。我写了半天遍历count 永远是 0后来打印类型才发现问题根源不在遍历逻辑而在数据本身。所以写代码之前先在关键位置打印一行std::cout depth m.depth() channels m.channels() total m.total() continuous m.isContinuous() std::endl;OpenCV 里 depth 是一个数字编号常见对应关系CV_8U0CV_8S1CV_16U2CV_16S3CV_32S4CV_32F5CV_64F6。如果你想统计小于0的个数只要 depth 是无符号类型——CV_8U、CV_16U——那结果一定是 0不用费劲遍历。如果数据是 CV_16S、CV_32S 这类有符号整型或者 CV_32F、CV_64F 浮点型才有统计负数的必要。遇到类型不对可以做一次convertTo比如把 CV_16S 转成 CV_32F 再统计。但要注意浮点转换对很大的整数可能引入精度损失如果数据范围本身不夸张一般没问题。我的习惯是在函数开头加一个断言CV_Assert(!m.empty()); CV_Assert(m.type() CV_32FC1 || m.type() CV_64FC1);这样类型不匹配时马上崩而不是带着错误的类型算出一个“看起来合理”的数字。2.2 单通道和多通道统计对象先定清楚单通道的情况最简单比如 CV_32FC1一个像素就是一个 float统计小于0就是数这些 float。多通道的情况要小心。以 CV_32FC3 为例每个像素有通道0、通道1、通道2三个值。统计时至少有三种口径统计所有通道值中小于0的个数相当于把三个通道拼接成一个长数组逐个判断。统计指定通道比如通道1小于0的像素数只关心某一维数据。统计“整像素三个通道都小于0”的个数这是像素级判断不是通道值级判断。如果你没想清楚就写atfloat(r, c)那默认只取了通道0另外两个通道全被忽略。如果你用指针按下标一个个数又可能把三个通道全算进去跟预期又不一样。实际工程里处理多通道的话我推荐两种做法std::vectorcv::Mat channels; cv::split(m, channels); int count0 cv::countNonZero(channels[0] 0);或者干脆在函数入口断言单通道逼着调用方先想清楚数据维度。2.3 空矩阵、子矩阵和连续性陷阱空矩阵是所有遍历操作的头号大敌。m.empty()为 true 时rows 和 cols 都是 0直接访问ptr可能崩total()返回 0 也可能让逻辑走偏。统计类函数开头的第一行永远应该是判断是否为空。子矩阵的坑更容易忽视。假设原图是 4000 × 3000你用bigImg(cv::Rect(100, 100, 500, 500))截了一块 ROI这块 ROI 的数据在内存里并不是 500 行连续排列的每行开头在原图某个位置行尾到下一行开头之间隔着原图的“剩余宽度”数据。此时isContinuous()返回 false如果你把它当成一维数组直接遍历total()个元素读到的后半部分全是错的。对付这种情况有两种选择调clone()把 ROI 复制成一块独立连续内存代价是额外一次拷贝。不 clone按“行指针 列数”的方式遍历代码稍微多几行但零拷贝。我的建议是如果这个 ROI 接下来还要反复用很多次可以 clone 一次如果只是统计一下数量直接按行遍历省掉拷贝开销。3. 四套完整实现从“能跑”到“跑得快”3.1 最直观但最慢的 at 遍历初学阶段最容易写出这样的代码int countNegativeAt(const cv::Mat m) { int count 0; for (int r 0; r m.rows; r) { for (int c 0; c m.cols; c) { if (m.atfloat(r, c) 0.0f) count; } } return count; }这段代码没毛病逻辑清晰小矩阵上跑得也很快。但到了大分辨率或者实时视频流里性能就开始吃亏了。at需要在运行时根据类型和行列号计算偏移地址虽然 release 版本下不做边界检查但每一层都多一次函数调用和索引换算累积起来相当可观。我在一台普通笔记本上测过一个 1920×1080 的单通道 float 矩阵这样写一轮大概要 20 多毫秒对于一帧 60 毫秒预算的视频处理来说光是数个数就占掉三分之一属实不划算。所以我的态度是at 遍历适合教学和验证思路不适合作为生产代码。3.2 工程首选compare countNonZero如果你只需要一个总数OpenCV 早就帮你把路铺好了int countNegativeCompare(const cv::Mat m) { cv::Mat mask; cv::compare(m, cv::Scalar(0.0), mask, cv::CMP_LT); int count cv::countNonZero(mask); return count; }这段代码的逻辑很好理解compare把 m 和标量 0 逐元素比较满足“小于”的像素在 mask 里写成 255不满足的写成 0。mask 的类型是 CV_8U。接着countNonZero数一下 mask 里有几个非零点因为 255 也是非零所以数量正好等于小于0的个数。这里要注意一个细节mask 的值是 255 而不是 1。如果你后面想对 mask 做求和记得除以 255如果用countNonZero因为只关心“是不是非零”255 和 1 没有区别直接数就行。compare countNonZero的组合之所以好用是因为这两个函数在 OpenCV 内部都做了大量优化底层线程库和 SIMD 指令会自己调度。你写的是一行代码实际跑起来可能是多线程并行处理的。而且代码短可维护性好是工程里的默认选择。唯一需要注意的是它会产生一个跟原图一样大的 mask 矩阵额外占用一份内存。对一张 4K 单通道 float 图来说原图约 33MBmask 约 8MB一般场景完全可以接受。如果连这 8MB 都敏感那就看下一节的手写指针方案。3.3 手写指针遍历与连续性优化在嵌入式设备上内存和性能都要抠着用我一般写这样的函数int countNegativePtr(const cv::Mat m) { CV_Assert(!m.empty()); CV_Assert(m.type() CV_32FC1); int count 0; if (m.isContinuous()) { const float* p m.ptrfloat(0); size_t total m.total(); for (size_t i 0; i total; i) { if (p[i] 0.0f) count; } } else { for (int r 0; r m.rows; r) { const float* p m.ptrfloat(r); for (int c 0; c m.cols; c) { if (p[c] 0.0f) count; } } } return count; }这段代码和 at 版本最大的区别是直接用ptrfloat拿到行首指针然后通过数组下标访问省掉了at的索引换算。对于连续矩阵一次拿到整块数据的起始指针按total()个 float 从头数到尾循环层数从嵌套变单层编译器更容易做向量化优化。isContinuous()分支是为了处理 ROI 子矩阵。连续时走一维循环不连续时老老实实一行行取指针。你可以留意到不连续分支和 at 版长得挺像但每行只调用一次ptrfloat后面是连续内存里的数组下标访问速度依然比at快一大截。这个版本没有额外分配内存占用最小。我经常在分析离线数据、处理大矩阵时用它。3.4 并行分段处理大矩阵的进阶方案矩阵大到一定程度单核遍历就浪费资源了。比如处理几千万个元素的浮点矩阵哪怕每元素操作再简单单线程也要几十毫秒。这时候可以上parallel_for_做行级并行int countNegativeParallel(const cv::Mat m) { CV_Assert(!m.empty()); CV_Assert(m.type() CV_32FC1); std::vectorint partial(m.rows, 0); cv::parallel_for_(cv::Range(0, m.rows), [](const cv::Range range) { for (int r range.start; r range.end; r) { const float* p m.ptrfloat(r); int cnt 0; for (int c 0; c m.cols; c) { if (p[c] 0.0f) cnt; } partial[r] cnt; } }); return std::accumulate(partial.begin(), partial.end(), 0); }这个版本把行号区间分给多个线程每个线程只处理自己负责的那几行结果写到partial[r]。因为每个线程写的数组槽位互不重叠所以不需要加锁最后在主线程里把 partial 累加一遍就行。要说明的是并行不是免费的。线程调度本身有开销如果矩阵只有几百行几千列并行版的耗时可能反而比单线程版更长。我的经验是至少百万级别元素以上再考虑并行不然收益不抵调度成本。另外OpenCV 底层如果编译了线程池compare本身也可能是并行的。所以我的建议顺序是先上 compare真的不够再上自己写的并行版。3.5 Python 版本的简洁写法如果你用的是 Python 的 OpenCV 接口底层矩阵其实是 numpy 数组统计负数可以直接用 numpy也可以走 OpenCV APIimport cv2 import numpy as np # 方法1numpy 布尔比较 求和最直观 cnt int(np.sum(mat 0)) # 方法2OpenCV 风格compare 生成 mask 再 countNonZero mask cv2.compare(mat, 0, cv2.CMP_LT) cnt cv2.countNonZero(mask)两种方式结果一致。numpy 版本在 Python 里更常用因为mat 0会生成一个布尔数组np.sum对它求和就是 True 的个数语义跟“小于0的个数”完全对应。如果说有什么要注意的那就是cv2.countNonZero的输入在多通道情况下不同版本表现不太一致稳妥做法是先cv2.split再分别统计而np.sum(mat 0)会把所有通道值都算进去。这跟前面说的“统计口径”是一回事Python 只是换了个写法该想清楚的还是得想清楚。4. 从“小于0”扩展到任意统计需求4.1 阈值区间与多条件组合统计小于0只是起点。实际需求往往是“统计负多少个”、“统计在-100到0之间有多少个”这类带条件的计数。在 C 里可以组合两个 compare 出来的 maskcv::Mat maskLow, maskHigh, maskBoth; cv::compare(m, cv::Scalar(-100.0), maskLow, cv::CMP_GT); // m -100 cv::compare(m, cv::Scalar(0.0), maskHigh, cv::CMP_LT); // m 0 cv::bitwise_and(maskLow, maskHigh, maskBoth); int count cv::countNonZero(maskBoth);这里统计的是同时满足“大于-100”和“小于0”的元素个数也就是区间 (-100, 0)。两个 mask 都是 0/255 的二值图bitwise_and会对每一位做与操作只有两个条件都成立的位置才保留 255。如果你想统计“小于-100 或 大于100”这种或条件就把bitwise_and换成bitwise_or。Python 版本一行就能写完cnt int(np.sum((mat -100) (mat 0)))numpy 的布尔数组可以自由组合、|、~在离线分析场景下非常顺手。4.2 按行/按列统计负数分布有时候你不仅想知道总共有多少个负数还想知道每一行、每一列分别有多少个。这种分布信息对定位问题区域特别有用比如深度图里哪一行无效点多或者残差图里哪一列偏差集中。思路还是先得到 mask再把它转成 0/1 的浮点矩阵用reduce做求和cv::Mat mask; cv::compare(m, cv::Scalar(0.0), mask, cv::CMP_LT); cv::Mat maskF; mask.convertTo(maskF, CV_32F, 1.0 / 255.0); cv::Mat rowCount; // rows x 1每行负数个数 cv::Mat colCount; // 1 x cols每列负数个数 cv::reduce(maskF, rowCount, 1, cv::REDUCE_SUM); cv::reduce(maskF, colCount, 0, cv::REDUCE_SUM);reduce的第二个参数是 int 类型0 表示按列方向求和输出每行一个结果1 表示按行方向求和输出每列一个结果。注意这里不能直接用 0/255 的 mask 做 reduce否则每行结果会是“255 × 负数个数”。先除以 255 变成 0/1reduce 出来的数值就直接是计数。用 Python 做这个事更简单np.sum(mask, axis1)一行搞定。4.3 计算异常占比统计出个数后很多场景还需要算一个比例。比如深度图无效像素占比double percent 100.0 * count / (double)m.total();这里要特别提醒total()对单通道矩阵就是像素数对多通道矩阵是像素数 × 通道数。如果你的统计口径是“通道值级”分母用 total() 没问题如果统计口径是“像素级”分母应该用m.cols * m.rows。这个占比在工程里很实用。我做某类图像质量评估时先把无效像素占比算出来一旦超过阈值就触发重新采集或告警。一个简单的数字配合阈值判断比肉眼盯着图像可靠得多。5. 实战排查我踩过的坑和速查表5.1 典型错误表格我把实际开发中遇到过的典型问题整理成了速查表遇到类似现象可以直接对号入座现象可能原因解决办法count 恒为 0depth 是 CV_8U / CV_16U 无符号类型打印 depth 确认必要时先 convertTocount 比预期大很多多通道 Mat 把所有通道都算了一遍先 split 或明确只统计指定通道编译报错或取出乱码用 at 访问 CV_64FC1 矩阵用 at 或统一转 CV_32F用 threshold 统计多了THRESH_BINARY_INV 把等于0也算进去了改用 compare countNonZero遍历越界或结果错乱ROI 不连续却当一维数组遍历先判断 isContinuous不连续时按行遍历数据包含 NaNNaN 比较小于0永远为 false被静默忽略需要专门分类处理 NaN 时先 patchNaNs表格里最后一行值得多说两句。浮点 Mat 里如果混入了 NaNNaN 0这个比较的结果永远是 false所以它不会被计入“负数”。如果你统计的是“异常无效值”而 NaN 恰恰也是异常之一那你需要先处理 NaN比如用cv::patchNaNs把它替换成某个固定值再统一统计。还有一个冷门细节负零。IEEE 浮点标准里有 -0.0它在比较时等于 0所以-0.0 0也是 false。大多数业务场景不会在意这零点几的差异但如果你的数据从数值计算里来要清楚这一点。5.2 性能对比一个 1080p 实例我在一台普通笔记本上用 Release 模式测过 1920×1080 单通道 CV_32FC1 矩阵的几种实现量级大致如下实现耗时量级额外内存备注at 双重循环15 ~ 30 ms无Debug 模式会更慢compare countNonZero0.2 ~ 1 msmask 约 2MB工程首选手写指针遍历0.5 ~ 1.5 ms无可控性最好parallel_for_ 并行0.2 ~ 0.8 ms少量 partial 数组大矩阵才划算这个表只是量级参考不同 CPU、不同编译器、不同 OpenCV 版本都会有很大的差异。但一个结论是稳定的at 版本跟其他方案完全不在一个数量级。如果你的代码里有一个“统计 Mat 负数个数”的函数卡在 20 毫秒别怀疑几乎一定是遍历方式的问题。我也经常看到有人习惯性 Debug 模式测性能数据惨不忍睹。记住高性能测试永远在 Release 模式下做。5.3 工程里的取舍建议结合这些年的经验我给自己定了一条规矩能用 OpenCV 内置 API 的绝不自己造循环。不是因为内置 API 一定更快而是它把边界情况都处理好了代码短出错概率低排查起来也省心。如果你的需求就是一个“总数”compare countNonZero 永远是第一选择。如果你在嵌入式平台内存紧张那就换手写指针版。如果你处理的是超大矩阵且性能瓶颈恰好卡在这个统计上再考虑并行。还有一个容易被忽略的点如果统计操作发生在视频循环里要避免在每一帧里反复分配 mask。虽然 OpenCV 的 Mat 在底层做了引用计数和内存复用但频繁的分配释放依然会有波动。你可以提前声明一个 mask在循环外用create保证尺寸类型然后每帧只调 compare 和 countNonZero降低分配频率。我在某个图像后处理项目里最初就是拿 at 版本统计深度图的负值无效区域一帧 1080p 卡在几十毫秒。后来换成 compare countNonZero时间掉到 1 毫秒以内。当时最大的感触就是很多所谓的“算法慢”其实只是工具选错了。所以如果你只需要一个数真的别再自己一层层写循环了。