☰
OpenCV+Qt颗粒计数:从二值化到连通域与界面排查
2026/10/28 4:50:39 网站建设 项目流程

简介:这份 OpenCV 细胞计数项目面向图像处理初学者与生物医学图像分析实践者,围绕细胞识别与自动统计展开,结合 Qt 搭建交互界面,可用于课程设计、项目练手或算法验证。压缩包为 rar 格式,共 41 个文件,约 7.6MB,包含 cpp、h、ui、pro 等源码与工程配置,dll、exe 等运行依赖及可执行程序,另有 png 效果图和 txt 说明,便于对照界面效果与处理流程。已有 1723 人学习,说明该案例具有一定参考价值。内容覆盖二值化、膨胀腐蚀、轮廓检测、连通域分析与计数等关键环节,读者可据此理解从图像预处理到结果可视化的完整思路,并借助 Qt 界面完成图像加载、显示与计数查看,适合作为 OpenCV 与 Qt 联调的实战样例。

1. 计数不准,八成不是数数那一步错了

用 OpenCV 做细胞计数或颗粒计数,最常见的出错方式是这样的:调好二值化阈值,跑一遍连通域标记,数字出来了,看着挺对;换一张同批次的图,数量直接差 40%。这时候大部分人开始怀疑连通域算法、换算子、换库,甚至怀疑 OpenCV 有 bug。

反直觉的地方在于:连通域标记几乎是确定性的,同一张二值图跑一百遍结果完全一样。它只干一件事——把你给的前景像素按邻接关系切成等价类。数量错了,错在二值图已经把两颗细胞连成了一片,或者把一颗细胞切成了三块,连通域只是忠实地数了数你喂给它的东西。Qt 那层更无辜,它只负责把结果画出来。

所以真正值得搞懂的不是“怎么调用计数函数”,而是“边界信息是在哪一步丢掉的”,以及在 Qt 里怎么把这个过程变得可观察、可回溯。

2. 连通域:两遍扫描怎么把像素切成等价类

连通域标记的数学定义很朴素:把二值图的前景像素当成无向图的节点,相邻的像素之间连一条边,求这个图的连通分量,每个分量给一个整数编号。麻烦的地方在“相邻”这个词——4 邻域和 8 邻域会得出完全不同的分量个数,而工业实现还得考虑内存访问顺序,不能真的建一张图去跑 BFS。

2.1 并查集与两遍扫描:标签为什么会“回头合并”

单遍逐像素扫描时,当前像素能看到的邻居只有左上方那几个已经处理过的。如果左上是标签 1、右上是标签 2,而当前像素同时和它们相邻,那 1 和 2 其实就是同一个连通域,必须合并。这种“后来的像素把先前两个标签缝合起来”的情况,就是并查集登场的原因。

下面这段是能跑的最小实现,重点是看合并发生在哪里:

import numpy as np class DSU: def __init__(self, n): self.p = list(range(n)) def find(self, x): # 路径压缩:把查找链上的节点直接挂到根上,摊还接近 O(1) while self.p[x] != x: self.p[x] = self.p[self.p[x]] x = self.p[x] return x def union(self, a, b): ra, rb = self.find(a), self.find(b) if ra != rb: self.p[max(ra, rb)] = min(ra, rb) # 始终让小编号当根,标签单调 def two_pass(binary, eight=True): h, w = binary.shape labels = np.zeros((h, w), dtype=np.int32) dsu = DSU(h * w // 2 + 1) nxt = 1 # 只取“已经扫描过”的方向:左上、上、右上、左 offsets = [(-1, -1), (-1, 0), (-1, 1), (0, -1)] if eight else [(-1, 0), (0, -1)] for y in range(h): for x in range(w): if not binary[y, x]: continue nb = [labels[y + dy, x + dx] for dy, dx in offsets if 0 <= y + dy < h and 0 <= x + dx < w and labels[y + dy, x + dx] > 0] if not nb: labels[y, x] = nxt nxt += 1 else: m = min(nb) labels[y, x] = m for n in nb: # 关键:把多个邻居代表的集合合并 dsu.union(m, n) # 第二遍:把所有等价标签压成从 1 开始的连续编号 remap = {} for y in range(h): for x in range(w): if labels[y, x]: r = dsu.find(labels[y, x]) if r not in remap: remap[r] = len(remap) + 1 labels[y, x] = remap[r] return labels, len(remap)

offsets只取左上四个方向,因为右上、下、左下这些邻居还没被读到,读了也是 0,这正是“两遍扫描”这个名字的来源。union里让小编号当根,是为了让第一遍结束时标签已经大致有序,第二遍重映射时字典不会太大。第二遍的重映射不能省:第一遍产生的标签有空洞(比如 1、4、7),后续如果拿标签当数组下标去索引面积表,直接越界。

这套实现在 Python 里跑一张 2000×2000 的图会慢到让人怀疑人生,因为纯 Python 双循环的常数太大了。生产环境里真正跑的是 OpenCV 的 C++ 版本,内部用的是行程编码(run-length)加并查集:先把每一行连续的 1 压成一个“段”,段与段之间做邻接判断,节点数量从像素级降到段级,在细胞这种前景占比不高的图上能快一到两个数量级。

2.2 connectedComponentsWithStats 的四个返回值,每一列到底是什么

调 OpenCV 的接口只有一行,但四个返回值的含义经常被记混:

import cv2 # bin_img 必须是单通道 8 位图,非零即前景 num, labels, stats, centroids = cv2.connectedComponentsWithStats( bin_img, connectivity=8, ltype=cv2.CV_32S) # num 包含背景,真实目标数 = num - 1 for i in range(1, num): # 从 1 开始,跳过背景标签 0 x, y, w, h, area = stats[i] if area < min_area or area > max_area: # 面积过滤干掉噪点和巨型粘连块 continue cx, cy = centroids[i] # 注意:是坐标均值,不是几何中心

stats的每一行固定 5 列,对应下面这几个常量:

列索引常量含义常见误用
0CC_STAT_LEFT外接矩形左上角 x当成质心 x 用
1CC_STAT_TOP外接矩形左上角 y当成质心 y 用
2CC_STAT_WIDTH外接矩形宽用它估算直径,斜向细长目标会大得离谱
3CC_STAT_HEIGHT外接矩形高同上
4CC_STAT_AREA前景像素个数当成真实面积,没做像素标定

CC_STAT_AREA是像素计数,不是几何面积。一个半径 20 像素的圆,理论面积约 1256,8 连通下数出来通常偏大几个百分点,因为边界上的锯齿像素被算成了完整像素。做浓度换算时这个系统偏差要提前用标准件标定掉,不然每一批数据的绝对值都会飘。

centroids是坐标的算术平均,对月牙形、环形目标会落在目标外面甚至落在背景上。如果用质心去判断“这个点是否属于该目标”或者做点击命中测试,遇到环形细胞就会出错,稳妥的做法是拿labels矩阵查标签值。

ltype这个参数值得单独说一句。默认CV_32S,标签上限二十多亿,基本够用;有人为了省内存改成CV_16U,标签上限 65535。一张噪点多、阈值又给低了的图,碎斑数量轻松破六万,超了之后 OpenCV 不会抛异常,标签直接回绕复用,于是原本分开的两个目标共用一个编号——这是典型的黑匣子式 bug,现象是“数量比实际少很多,但图上看不出问题”。

2.3 4 邻域还是 8 邻域:一条对角线决定的计数差

邻域选择不是风格问题,它直接改变等价类的划分。看一个 3×3 的例子:

1 0 0 0 1 0 0 0 1

三个像素两两只在斜对角相接。8 邻域下它们是一条链,算 1 个连通域;4 邻域下互不相邻,算 3 个。反过来,一个 L 形:

1 1 0 1 0 0 0 0 0

两种邻域都算 1 个,没有分歧。

结论可以概括成一句话:8 连通偏“少”,4 连通偏“多”。8 连通把斜向相切的两个细胞并成一颗,4 连通把一条 45° 的细长结构切成好几段。对近似圆形的细胞、颗粒,8 连通更符合人眼直觉,所以 OpenCV 生态里的计数示例基本都用 8;对纤维、裂纹、血管这类细长目标,4 连通反而更稳。

还有一个容易被忽略的点:8 连通下单个孤立噪点很容易“桥接”两个本来分开的目标。两个细胞之间只要有一个噪点落在它们的斜对角空隙里,三者的标签就会被并查集合并成一颗。处理顺序应该是先滤波再标记,而不是先标记再想办法补救——3×3 中值滤波,或者半径 1 的椭圆核开运算,成本很低。但开运算会把贴边目标的面积削掉,这是下一章要展开的坑。

3. 二值化与形态学:误差真正的产地

连通域标记只是把边界翻译成数字,边界本身是在二值化和形态学这两步定下来的。计数结果的系统性偏差,九成出在这里。

3.1 Otsu 的隐含假设,以及它在光照不均时怎么出错

Otsu 做的事情是:遍历所有可能的阈值 t,把直方图分成两类,选让类间方差最大的那个 t。它的推导里藏着三个假设——直方图是双峰的、两类的类内方差大致相当、目标像素占比不能太低。三条里破一条,阈值就开始偏。

最常破的是第一条。显微图、工业相机拍的反光件、带渐变的背景,直方图根本不是双峰而是一个大包加一个小尾巴。这时 Otsu 会把阈值推到很靠边的位置,结果是前景大片丢失,或者整片糊成一坨。判断方法很土但很有效:把直方图打出来看一眼。

import cv2, numpy as np gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) t, bin_img = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).ravel() # 打印双峰位置和谷底,双峰不明显就别指望全局阈值了 print("otsu threshold =", t, "peak1 =", int(np.argmax(hist[:int(t)])), "peak2 =", int(np.argmax(hist[int(t):]) + t))

如果两个峰的高度差超过一个数量级,或者两峰之间没有明显的谷,那基本可以判定全局阈值不适合这批图。目标占比低于 5% 时尤其明显——Otsu 会把阈值往背景那边推,因为把少量目标错分成背景,对类间方差的损失比反过来小。

3.2 平场校正:把“不均匀”变成“均匀”

光照不均的常规解法是先估计背景,再做差。思路是:用一个远大于目标尺寸的核做形态学开运算,目标被抹掉,只剩下缓慢变化的背景,然后原图和背景相减,得到一张背景被拉平的图。

# 核尺寸必须大于最大目标的直径,否则背景估计里会带上目标本身 k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (101, 101)) bg = cv2.morphologyEx(gray, cv2.MORPH_OPEN, k) # 亮背景、暗目标的场景 # 或者用闭运算估计亮目标、暗背景场景下的背景 flat = cv2.subtract(bg, gray) # 背景亮、目标暗:反相 _, bin_img = cv2.threshold(flat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

这里最关键的参数是核尺寸,也是最容易搞错的地方。核小于目标直径时,开运算抹不掉目标,背景估计里就带着目标的灰度,相减之后目标被削平甚至消失,现象是“调大核,细胞突然全没了”。经验值取最大目标直径的 1.5 到 2 倍,但别大到大半个图——核越大,对光照渐变的空间分辨率越低,边缘的校正会不足。

cv2.subtract会在结果为负时截断到 0,所以顺序不能反。如果目标是亮的、背景是暗的,就得用cv2.subtract(gray, bg),判据是目标比背景亮还是暗,弄反了整张图全黑。这一条听起来很蠢,但换一种成像方式(明场换暗场)时,翻车概率相当高。

3.3 形态学核尺寸如何系统性地改变计数

开运算是先腐蚀后膨胀,效果是去掉比结构元小的亮结构、断开细连接;闭运算相反,填小洞、接断缝。两者都会改变目标面积,而面积过滤的阈值是拿正常目标标定的,面积一偏,过滤就开始误杀或漏杀。

结构元半径 r 对单个目标面积的影响大致是:面积减少量正比于周长乘以 r。对一个等效半径 R 的圆,开运算后大约少掉 2πR·r 量级的像素。R=20、r=3 时,面积从 1256 掉到约 1050,掉了 16%。如果面积下限是按未开运算时的分布定在 1000,那开运算之后一批小目标就会被整批误杀,数量突然掉一截,而图上看每个目标都还在。

一个粗糙但好用的约束:r 不超过最小目标等效半径的三分之一。再大就要考虑换方法(比如用分水岭拆粘连),而不是靠形态学硬切。

还有个反直觉的细节:OpenCV 的腐蚀默认边界处理是morphologyDefaultBorderValue(),对 8 位图相当于把边界外当成正无穷。这意味着贴在图像边缘的目标不会被腐蚀掉,也就不会因为开运算而缩水。很多人以为贴边目标会被切掉一半进而误判,实际上缩水的是膨胀那一步的方向感——真正需要担心的是贴边目标被图像边界截断,面积本来就只有一半,面积下限会把它判成噪点。稳妥做法是在图像四周 pad 一圈背景,或者单独统计 touching border 的目标数量并排除。

3.4 距离变换加分水岭:粘连目标怎么拆

两个细胞粘在一起,形态学开运算要么切不开,要么切开的同时把别的目标也削了。这时候正确工具是距离变换。

距离变换给每个前景像素赋一个值:到最近背景像素的距离。两颗相切的圆,各自的圆心附近距离值最大,相切处距离值出现一个鞍部。对距离图取一个阈值(通常是最大距离的 0.5 到 0.7 倍),高于阈值的区域自然分裂成几个“核”,每个核对应一颗目标,这就是种子。最后用种子做 marker 跑分水岭,让区域在原始梯度图上生长,遇到边界脊线停下。

# 前提:bin_img 是干净的二值图,粘连目标在里面已经是连成一片的 dist = cv2.distanceTransform(bin_img, cv2.DIST_L2, 5) # 阈值取最大距离的比例,比例越低越容易过分割 _, peaks = cv2.threshold(dist, 0.6 * dist.max(), 255, 0) peaks = peaks.astype(np.uint8) # 种子的连通域标记,注意这里用 4/8 连通都行,种子一般不会斜接 n_seeds, seeds = cv2.connectedComponents(peaks) markers = seeds.astype(np.int32) + 1 # 背景改成 1,种子从 2 开始 markers[bin_img == 0] = 1 # 未知区域先给 1 cv2.watershed(cv2.cvtColor(bin_img, cv2.COLOR_GRAY2BGR), markers) # watershed 会把区域边界标成 -1,统计数量时记得排除 count = len(np.unique(markers)) - 2 # 减背景 1 和边界 -1

0.6这个比例是最需要调的参数。调低了,一颗细胞的距离图上可能出现两个局部极大(尤其是椭圆形目标的长轴两端),种子多出来,一颗被拆成两颗,数量虚高;调高了,两颗粘连较紧的细胞只有一个种子,退化成一颗,数量偏低。更稳的做法是找距离图的局部极大值再做非极大值抑制,而不是直接对距离图取全局阈值——全局阈值对尺寸差异大的样本很不友好。

另一个细节:分水岭跑完之后,markers里每个区域是一个标签,但区域边界是 -1,紧贴着边界的像素没有被分配。用len(np.unique(markers)) - 2数数量是对的,但如果你要按标签去取每个区域的面积,-1那部分会被漏掉,最后算出来的总面积比前景像素总数少一圈。

4. Qt 层:图像数据、坐标系与刷新节奏

算法跑通只是前半程。真正让人抓狂的问题,往往出在“把 cv::Mat 塞进 Qt 显示”这一步:画面花屏、颜色反了、点选坐标差几个像素、界面越跑越卡。这些都不是玄学,每一条背后都有明确的内存或坐标系原因。

4.1 cv::Mat 与 QImage:三个必须对齐的参数

cv::Mat和QImage的内存布局并不天然兼容,转换时要盯住三件事:通道顺序、行跨度、生命周期。

QImage matToQImage(const cv::Mat& src) { if (src.type() == CV_8UC3) { cv::Mat rgb; cv::cvtColor(src, rgb, cv::COLOR_BGR2RGB); // OpenCV 是 BGR,Qt 要 RGB // 1) 传入 rgb.step 作为 bytesPerLine,避免行对齐错位 // 2) 必须 .copy(),QImage 不接管 rgb 的内存 return QImage(rgb.data, rgb.cols, rgb.rows, static_cast<int>(rgb.step), QImage::Format_RGB888).copy(); } if (src.type() == CV_8UC1) { return QImage(src.data, src.cols, src.rows, static_cast<int>(src.step), QImage::Format_Grayscale8).copy(); } return QImage(); }

通道顺序错了,画面上的红色和蓝色互换,很多人第一反应是“相机白平衡出了问题”,其实是没做COLOR_BGR2RGB。行跨度这一条更隐蔽:QImage 要求每行 4 字节对齐,而cv::Mat为了保证 SIMD 访存效率,step可能等于宽度乘以通道数,也可能因为分配策略而更大。不传step让 QImage 自己按宽度算,遇到宽度不是 4 的倍数时,图像就会斜着错位,每行往右偏一点,整张图看起来像被剪切过。

.copy()这三个字符是血泪经验。不拷贝的话,rgb是局部变量,函数返回时析构,QImage 还指着一块已经释放的内存——界面刷新的时候才崩,或者显示半张图加半张花屏,调试起来极其难受。Qt 5.14 之后有QImage::Format_BGR888,可以省掉那次cvtColor,但.copy()该有还得有。

4.2 缩放、高 DPI 与坐标反算

做颗粒计数,用户必然要在图上点选、框选 ROI、看某个目标的属性。一旦图像被缩放显示,控件坐标和图像像素坐标之间就隔了一层变换,处理不干净,点选就会“永远差几个像素”。

推荐用QGraphicsView+QGraphicsPixmapItem,让视图自己管变换:

// view 是 QGraphicsView,pixmapItem 里装着整张图 void onMousePress(const QPoint& widgetPos) { QPointF scenePos = view->mapToScene(widgetPos); // 场景坐标 QPointF imgPos = pixmapItem->mapFromScene(scenePos); // 图像像素坐标 int px = static_cast<int>(imgPos.x()); int py = static_cast<int>(imgPos.y()); if (px < 0 || py < 0 || px >= labels.cols || py >= labels.rows) return; int label = labels.at<int>(py, px); // 直接查标签矩阵 }

用QGraphicsView的好处是:视图的缩放和滚动都体现在 transform 里,mapToScene一步就能回到图像坐标系,中间不用手动维护缩放比和偏移量。手写QLabel+setPixmap缩放的方案,就得自己记scale和offset,还要处理窗口 resize 时比例变化,稍不留神就错位。

高 DPI 屏上还有一层:如果给QPixmap设了devicePixelRatio,视图会按物理像素渲染,但 scene 坐标仍然对应图像像素,点选逻辑不用改;如果反过来在控件坐标上乘除了 dpr,就会差出一倍。一个原则——坐标变换全部交给 view/scene,不要在半路手动乘除缩放系数。

最后,命中测试用labels矩阵查标签,不要用外接矩形或者质心距离去判断。外接矩形对斜向目标会明显过大,点在外接矩形里但不在目标上的情况很常见;质心对环形目标直接落在孔里。

4.3 采集、处理、显示三段解耦

把算法塞进界面线程的槽函数里,是性能崩掉的头号原因。一帧 4000×3000 的图,二值化加分水岭轻松跑到几百毫秒,界面在这期间完全卡死,鼠标点击全部排在事件队列里,用户以为程序挂了。

结构上拆成三段:采集线程只管抓图,处理线程只管算,界面线程只管渲染,中间用信号槽的队列连接(Qt::QueuedConnection)传递。传递的对象要带序号,因为处理一定比采集慢,队列会积压:

struct Frame { int seq; // 帧号,界面只渲染最新的 std::shared_ptr<cv::Mat> image; // 共享所有权,避免深拷贝 int count; }; // 处理线程里 emit frameReady(frame); // 跨线程自动走 QueuedConnection // 界面线程里 void onFrameReady(const Frame& f) { if (f.seq <= lastRenderedSeq) return; // 丢掉过期帧,防止队列越积越长 lastRenderedSeq = f.seq; label->setPixmap(QPixmap::fromImage(matToQImage(*f.image))); }

用std::shared_ptr<cv::Mat>而不是直接传cv::Mat,原因是cv::Mat的拷贝构造是浅拷贝(引用计数加一),跨线程传递时如果源对象被复用或释放,另一头会拿到悬垂数据;用 shared_ptr 把所有权语义写明确,比依赖引用计数的时序更安全。丢帧策略则必须有——没有它,处理慢的时候信号槽队列会无限增长,内存一路涨到 OOM。

4.4 标注层别画在原图上

新手很容易顺手cv2.rectangle画在原图上再转 QImage 显示。问题是原图被污染了:用户想重新调阈值重跑一遍,手上只有画过框的图,框里的红色像素会参与下一次二值化和面积统计,误差越叠越大。

正确做法是保留两套数据:原始图和标签矩阵。显示时先生成底图,再把标签矩阵按调色板映射成伪彩色,用cv::addWeighted合成一层半透明 overlay,最后一起转成 QImage。这样参数一改,重跑只影响标签矩阵,底图永远干净,随时可以回到任意一步重来——相当于给自己留了后悔药。

5. 避坑与排查:计数对不上时按这个顺序看

计数类问题的排查最忌讳“猜”。下面这几条是实践中反复出现的,每条都能从现象直接倒推到具体原因,按顺序过一遍基本能定位。

现象一:同一张图连跑两次,数量差一两个。原因通常是计数依赖了标签编号或者容器迭代顺序。OpenCV 的标签分配跟扫描顺序相关,中间插入一步滤波、换一个 ROI、甚至改变图像尺寸,标签编号就可能重排。解决办法是计数只依赖“面积过滤后的目标个数”,需要稳定输出时按面积或质心坐标排个序再落盘,不要拿标签号当唯一键。

现象二:计数明显偏少,放大看是两三个粘成一坨。8 连通把斜向相切的目标并掉了,或者根本没做拆分。先看距离变换的结果:如果距离图上一坨区域只有一个峰,说明它们确实贴得太紧,得靠分水岭甚至提高成像分辨率。换 4 邻域通常不是解法,它会把同一个目标的锯齿边缘切碎,数量反而暴涨。

现象三:计数明显偏多,多出来的都是边缘一圈碎点。三个来源:贴边目标被截断后面积只剩一半,被面积下限误杀(这时数量偏少);JPEG 压缩在目标边缘产生的振铃被阈值切成了独立小块;开运算的核太小,噪声没去干净。排查顺序是先做中值滤波,再看贴边目标数量,最后调核尺寸。贴边目标建议单独统计并标注,别混进主计数。

现象四:换一台机器或换一张同批次图,数量差 30% 以上。几乎可以断定是光照或曝光变了,全局 Otsu 的阈值跟着漂。这类问题不能靠调阈值解决,要么固定光源和曝光,要么上平场校正。判断方法很直接:把两张图的 Otsu 阈值打印出来,差得超过 10 个灰度级就说明成像条件不一致,后续所有数据的绝对值都不可比。

现象五:界面显示的数量和日志里的数量对不上。典型是界面渲染的是过期帧,而日志记的是最新结果。信号槽队列积压时,界面按顺序把每一帧都渲染了,用户看到的是几秒前的数字。解决靠帧号加丢帧策略,界面只渲染最新序号,日志和界面用同一个Frame结构,避免两条路径各自算一遍。

现象六:跑久了内存一直涨。两个高发点:QImage转换时没.copy(),导致 Qt 侧持有悬垂指针,Qt 内部的隐式共享计数错乱;以及跨线程信号传大对象时按值传递又没有移动语义,每次 emit 都深拷贝一份几十兆的图。前者表现为花屏,后者表现为内存曲线锯齿上升。

6. 设计取舍:把不可复现的部分显式化

自动阈值和手工调参的争论,在计数类项目里通常问错了方向。真正决定这套东西能不能用的,不是“全自动”还是“半自动”,而是“同一个样本换个人跑、换台机器跑,结果是否一致”。全自动的 Otsu 在光照稳定的产线上非常可靠,在明暗变化大的场景里反而不如一个固定阈值加平场校正来得稳定——稳定的错误比不稳定的正确更容易被工程接受,因为前者可以标定掉。

真正的取舍落在三个点上:

方案适用场景代价
全局 Otsu背景均匀、目标占比 10% 以上、成像条件固定光照一变就漂,且不报错
平场校正 + 固定阈值背景有渐变、样本批量一致需要每个批次标定一次阈值,核尺寸依赖目标直径
自适应阈值 / 分水岭目标尺寸差异大、粘连严重参数多,过分割和欠分割之间的窗口窄

我倾向于把参数快照当成一等公民:每处理一张图,把阈值、核尺寸、面积上下限、被过滤掉的目标数量、面积分布的分位数一起落盘,跟结果存在同一条记录里。

import json record = { "threshold": float(t), "open_kernel": 3, "area_min": int(min_area), "area_max": int(max_area), "count": int(count), "filtered_low": int(n_low), # 被下限干掉的,通常是噪点或碎块 "filtered_high": int(n_high), # 被上限干掉的,通常是巨型粘连 "area_p10": float(np.percentile(areas, 10)), "area_p90": float(np.percentile(areas, 90)), } json.dump(record, open(f"{stem}.json", "w"), ensure_ascii=False, indent=2)

这套记录的价值在事后。数量对不上的时候,翻记录就能看出是阈值漂了、还是filtered_high突然变大(说明粘连变严重),或者area_p10整体下移(说明对焦或曝光变了)。没有这层记录,排查只能靠重跑和猜,效率差一个量级。

一个容易被跳过的验证方法:拿一张人工数过的图(哪怕只有二三十个目标)做基准,把参数扫一遍,画出数量随阈值变化的曲线。曲线在某个区间内平坦,说明这个参数处在稳定区,可以放心用;曲线一路上翘或者一路下滑,说明当前场景根本不适合自动阈值,得回去改成像条件。这个曲线比任何单点精度指标都更能说明系统的鲁棒性。

从那以后我每次做计数类需求,都强制先把二值图连同阈值一起落盘,再看数字——数字是果,二值图才是因,跳过因直接看果,等于在调一个自己看不见的黑匣子。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询