看到这个标题——opencv mat 统计小于0的个数——你可能觉得这就是个遍历计数的小问题。我一开始也这么认为,结果真上手的时候,被 Mat 的类型、通道数、连续性轮番招呼了一遍。OpenCV 的 Mat 不是一个简单的一维数组,它背后有内存布局、数据类型、通道等一堆概念,任何一个理解错了,统计出来的数都可能离谱。这个需求其实很常见。举几个我碰过的场景:深度传感器输出的深度图里,无效点经常用负值标记,统计负值个数就是评估一帧深度图像质量的快捷方式;两张图像做差分后,残差矩阵里的负值代表某一个方向的变化量,统计它能判断场景中是否有遮挡或光照突变;训练回归模型做图像重建时,预测值与真值相减得到误差图,负误差的比例会影响后续的调参策略。在这些场景里,一个可靠的“负值计数”往往只是第一步,但如果不把它写对,后面的逻辑全都白搭。这篇文章会把这个问题拆开聊:先从 Mat 的内存结构讲起,再给四套不同侧重点的实现代码,最后整理我在实操中踩过的坑和排查思路。不管你是刚接触 OpenCV 的初学者,还是写了多年 C++ 但一碰到 Mat 就头疼的老手,都能在里头找到一段能直接拿去用的代码。1. 需求拆解:为什么一个计数问题能写出这么多花样1.1 实际场景决定统计口径很多人看到“统计小于0的个数”,第一反应就是“数呗”。但真正做工程时,先要搞清楚一件事:你数的到底是“像素”还是“通道值”?比如一张 CV_32FC3 的三通道浮点图,一个像素位置上有三个 float。如果按“像素数”统计,那一个像素只要有任意一个通道小于0,要不要算一次?如果三个通道都小于0,是算一次还是算三次?如果我关心的是第二个通道(比如某个算法输出的深度分量),那就只该统计那个通道。我见过不少同事直接把代码写成m.at<float>(r, c) < 0,然后对着结果百思不得其解。原因很简单:at<float>对一个三通道 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的个数,我在不同项目里用过四类写法,各有优劣:方案优点缺点适用场景at<T>(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_8U=0,CV_8S=1,CV_16U=2,CV_16S=3,CV_32S=4,CV_32F=5,CV_64F=6。如果你想统计小于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”的个数:这是像素级判断,不是通道值级判断。如果你没想清楚就写at<float>(r, c),那默认只取了通道0,另外两个通道全被忽略。如果你用指针按下标一个个数,又可能把三个通道全算进去,跟预期又不一样。实际工程里处理多通道的话,我推荐两种做法:std::vector<cv::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.at<float>(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 图来说,原图约 33MB,mask 约 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.ptr<float>(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.ptr<float>(r); for (int c = 0; c < m.cols; ++c) { if (p[c] < 0.0f) ++count; } } } return count; }这段代码和 at 版本最大的区别是:直接用ptr<float>拿到行首指针,然后通过数组下标访问,省掉了at的索引换算。对于连续矩阵,一次拿到整块数据的起始指针,按total()个 float 从头数到尾,循环层数从嵌套变单层,编译器更容易做向量化优化。isContinuous()分支是为了处理 ROI 子矩阵。连续时走一维循环,不连续时老老实实一行行取指针。你可以留意到不连续分支和 at 版长得挺像,但每行只调用一次ptr<float>,后面是连续内存里的数组下标访问,速度依然比at快一大截。这个版本没有额外分配,内存占用最小。我经常在分析离线数据、处理大矩阵时用它。3.4 并行分段:处理大矩阵的进阶方案矩阵大到一定程度,单核遍历就浪费资源了。比如处理几千万个元素的浮点矩阵,哪怕每元素操作再简单,单线程也要几十毫秒。这时候可以上parallel_for_做行级并行:int countNegativeParallel(const cv::Mat& m) { CV_Assert(!m.empty()); CV_Assert(m.type() == CV_32FC1); std::vector<int> 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.ptr<float>(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 API:import cv2 import numpy as np # 方法1:numpy 布尔比较 + 求和,最直观 cnt = int(np.sum(mat < 0)) # 方法2:OpenCV 风格,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 出来的 mask:cv::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/1,reduce 出来的数值就直接是计数。用 Python 做这个事更简单,np.sum(mask, axis=1)一行搞定。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 里如果混入了 NaN,NaN < 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 毫秒以内。当时最大的感触就是:很多所谓的“算法慢”,其实只是工具选错了。所以如果你只需要一个数,真的别再自己一层层写循环了。
看到这个标题——opencv mat 统计小于0的个数——你可能觉得这就是个遍历计数的小问题。我一开始也这么认为,结果真上手的时候,被 Mat 的类型、通道数、连续性轮番招呼了一遍。OpenCV 的 Mat 不是一个简单的一维数组,它背后有内存布局、数据类型、通道等一堆概念,任何一个理解错了,统计出来的数都可能离谱。这个需求其实很常见。举几个我碰过的场景:深度传感器输出的深度图里,无效点经常用负值标记,统计负值个数就是评估一帧深度图像质量的快捷方式;两张图像做差分后,残差矩阵里的负值代表某一个方向的变化量,统计它能判断场景中是否有遮挡或光照突变;训练回归模型做图像重建时,预测值与真值相减得到误差图,负误差的比例会影响后续的调参策略。在这些场景里,一个可靠的“负值计数”往往只是第一步,但如果不把它写对,后面的逻辑全都白搭。这篇文章会把这个问题拆开聊:先从 Mat 的内存结构讲起,再给四套不同侧重点的实现代码,最后整理我在实操中踩过的坑和排查思路。不管你是刚接触 OpenCV 的初学者,还是写了多年 C++ 但一碰到 Mat 就头疼的老手,都能在里头找到一段能直接拿去用的代码。1. 需求拆解:为什么一个计数问题能写出这么多花样1.1 实际场景决定统计口径很多人看到“统计小于0的个数”,第一反应就是“数呗”。但真正做工程时,先要搞清楚一件事:你数的到底是“像素”还是“通道值”?比如一张 CV_32FC3 的三通道浮点图,一个像素位置上有三个 float。如果按“像素数”统计,那一个像素只要有任意一个通道小于0,要不要算一次?如果三个通道都小于0,是算一次还是算三次?如果我关心的是第二个通道(比如某个算法输出的深度分量),那就只该统计那个通道。我见过不少同事直接把代码写成m.at<float>(r, c) < 0,然后对着结果百思不得其解。原因很简单:at<float>对一个三通道 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的个数,我在不同项目里用过四类写法,各有优劣:方案优点缺点适用场景at<T>(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_8U=0,CV_8S=1,CV_16U=2,CV_16S=3,CV_32S=4,CV_32F=5,CV_64F=6。如果你想统计小于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”的个数:这是像素级判断,不是通道值级判断。如果你没想清楚就写at<float>(r, c),那默认只取了通道0,另外两个通道全被忽略。如果你用指针按下标一个个数,又可能把三个通道全算进去,跟预期又不一样。实际工程里处理多通道的话,我推荐两种做法:std::vector<cv::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.at<float>(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 图来说,原图约 33MB,mask 约 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.ptr<float>(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.ptr<float>(r); for (int c = 0; c < m.cols; ++c) { if (p[c] < 0.0f) ++count; } } } return count; }这段代码和 at 版本最大的区别是:直接用ptr<float>拿到行首指针,然后通过数组下标访问,省掉了at的索引换算。对于连续矩阵,一次拿到整块数据的起始指针,按total()个 float 从头数到尾,循环层数从嵌套变单层,编译器更容易做向量化优化。isContinuous()分支是为了处理 ROI 子矩阵。连续时走一维循环,不连续时老老实实一行行取指针。你可以留意到不连续分支和 at 版长得挺像,但每行只调用一次ptr<float>,后面是连续内存里的数组下标访问,速度依然比at快一大截。这个版本没有额外分配,内存占用最小。我经常在分析离线数据、处理大矩阵时用它。3.4 并行分段:处理大矩阵的进阶方案矩阵大到一定程度,单核遍历就浪费资源了。比如处理几千万个元素的浮点矩阵,哪怕每元素操作再简单,单线程也要几十毫秒。这时候可以上parallel_for_做行级并行:int countNegativeParallel(const cv::Mat& m) { CV_Assert(!m.empty()); CV_Assert(m.type() == CV_32FC1); std::vector<int> 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.ptr<float>(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 API:import cv2 import numpy as np # 方法1:numpy 布尔比较 + 求和,最直观 cnt = int(np.sum(mat < 0)) # 方法2:OpenCV 风格,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 出来的 mask:cv::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/1,reduce 出来的数值就直接是计数。用 Python 做这个事更简单,np.sum(mask, axis=1)一行搞定。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 里如果混入了 NaN,NaN < 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 毫秒以内。当时最大的感触就是:很多所谓的“算法慢”,其实只是工具选错了。所以如果你只需要一个数,真的别再自己一层层写循环了。