ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenCV物体计数实战:预处理、轮廓筛选与轨迹栏调参

OpenCV物体计数实战:预处理、轮廓筛选与轨迹栏调参

简介:这份文档面向计算机视觉入门者与本科课程设计学生,主题为基于OpenCV的物体计数与表单信息系统,解决图像中目标自动框选、计数及检测信息表格化记录的问题,可服务于零售、安防、交通监控等场景的入门实践,也可作为软件技术项目设计的报告范例。压缩包约19.51MB,内含1个docx,正文覆盖任务书、摘要、需求分析、系统功能模块图、实现过程、学生日志与综合评价表,完整呈现从图像预处理、边缘检测、目标检测到轨迹栏参数调节、图片与视频双模式及控制台交互检测的推进脉络,并附答辩评分维度,方便对照梳理报告结构与复现思路。目前已有215人学习,适合需要理解轮廓检测、Canny、Haar级联等基础方法并参考课程设计写作与项目排期的读者。

1. 从一张数不准的零件图说起:OpenCV 物体计数为什么先调预处理

产线上要数一批散放的零件,摄像头架好、画面也清楚,但脚本一跑,两个挨着的零件被数成一个,换个工位光照一变,阈值就全废。这类问题往往不是换个模型能解决的,先把 OpenCV 的预处理链路和计数判定规则理清楚,比直接上深度学习见效更快。这份课程设计做的就是这件事:基于 OpenCV 的物体计数与表单信息系统,用轨迹栏实时调参,用轮廓加外接矩形把物体框出来计数,按s键把时间、文件名、数量写进 Excel。全部逻辑收在一个tally.py里,分实时检测、图片检测、控制台交互三种模式。适合刚接触 OpenCV、需要一套能跑通又能改参数的计数基线的人,也适合拿它当课程设计或小工具原型的从业者。

2. 环境与模块骨架:tally.py 该拆成哪几层、轨迹栏怎么挂

2.1 依赖与版本:把 opencv-python 钉在 4.x

这套代码对 OpenCV 版本敏感,最典型的是cv2.findContours的返回值个数。3.x 返回三个值(image、contours、hierarchy),4.x 只返回两个(contours、hierarchy)。如果你从旧环境拷代码过来,第一处报错大概率在这里。所以先把版本钉死,虚拟环境里装:

python -m venv venv # Windows venv\Scripts\activate # Linux / macOS source venv/bin/activate pip install opencv-python==4.9.0.80 pip install pandas==2.1.4 openpyxl==3.1.2 numpy==1.26.2

opencv-python选主包就够了,这个项目只用到imgproc、highgui、videoio三个模块。opencv-contrib-python里的 SIFT 那套在方案对比阶段试过,最终没走特征匹配路线,装上只是增加体积。openpyxl必须显式安装,pandas.to_excel写.xlsx时靠它做引擎,不装会在运行时报ImportError: Missing optional dependency 'openpyxl'。numpy和 OpenCV 有 ABI 依赖关系,1.26 与 4.9 搭配比较稳,别随手升到 2.x。

提示:如果机器上已经有别的项目在用 OpenCV 3.x,不要动全局环境,给这套代码单独建 venv,否则改一个版本会连带把老项目搞崩。

2.2 模块划分:六个职责,一个文件

课程设计规模不大,全部塞进tally.py是合理的,拆成包反而多出一堆 import 路径问题。但函数边界要清楚,后面想把检测层换成模型,只改一处就行。

模块关键函数 / 入口输入 → 输出职责
采集data_collection.py主循环、cv2.VideoCapture摄像头帧 → 图片文件拍照并落盘,按s保存
预处理preProcessing(img)BGR 图 → 单通道边缘图灰度、模糊、Canny、膨胀、闭运算
检测计数findContours(img, imgPre, ...)原图 + 边缘图 → 画框图、轮廓列表、数量筛轮廓、画框、计数
表单写入readPath、excelmation、ImagePath数据字典 → Excel 行路径解析、追加写入
交互轨迹栏回调、cv2.waitKey鼠标/键盘 → 参数值实时调参、保存触发
调度控制台main()菜单输入 → 模式分支实时 / 多图 / 单图切换

readPath(folder_path)负责把文件夹里的图片路径读成列表,只保留.jpg、.png后缀,避免把临时文件混进去。ImagePath(recongnitionMode)根据模式字符串长度决定保存目录,实时模式和多图模式写到不同文件夹,防止图片和表格互相覆盖。excelmation(excel_path, data)是写入入口,输入是一个字段有序的字典。这几个函数看着琐碎,但它们是「检测结果能不能变成可查数据」的关键,比检测本身更容易翻车。

常见的目录结构是这样:

project/ tally.py # 核心模块 data_collection.py # 拍照脚本 images/ # 采集图 output/ realtime/ # 实时模式截图 multi/ # 多图模式结果 records.xlsx # 表单

2.3 轨迹栏绑定:先建窗口,再挂滑块

轨迹栏是这套系统能做参数实时修正的核心。它挂在窗口上,窗口名必须和createTrackbar的目标完全一致,包括大小写和空格。顺序错了会直接抛cv2.error: ... window not found。

import cv2 import numpy as np WINDOW = "counter" def nothing(x): pass # 必须先建窗口,再创建轨迹栏 cv2.namedWindow(WINDOW, cv2.WINDOW_NORMAL) cv2.resizeWindow(WINDOW, 960, 540) cv2.createTrackbar("Threshold1", WINDOW, 30, 255, nothing) cv2.createTrackbar("Threshold2", WINDOW, 80, 255, nothing) cv2.createTrackbar("MinArea", WINDOW, 1000, 20000, nothing) cv2.createTrackbar("BoxScale", WINDOW, 50, 100, nothing) # 实际值 = 读数 / 100

逻辑上,createTrackbar的第四个参数是滑块最大值,第五个是回调函数。回调体可以是空的,因为读取靠getTrackbarPos轮询,回调只在需要「拖动即触发」的场景下才写逻辑。BoxScale之所以用 0 到 100 的整数,是因为轨迹栏只接受整数,实际使用时除以 100 得到 0.0 到 1.0 的比例。

参数上限不是随便给的。Threshold1、Threshold2的上限 255 对应 Canny 的 8 位输入范围;MinArea上限 20000 是按 640×480 画面里最大物体约占 16000 像素估的,如果换成 1280×720,上限要同步放大到 60000 左右,否则滑块一拉就顶到头,调不出有效区间。主循环的骨架大致是:

while True: ok, frame = cap.read() if not ok: break imgPre = preProcessing(frame) imgBox, conFound, total = findContours(frame, imgPre, minArea=cv2.getTrackbarPos("MinArea", WINDOW)) cv2.putText(imgBox, f"count={total}", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2) cv2.imshow(WINDOW, imgBox) key = cv2.waitKey(1) & 0xFF if key == ord('s'): # 保存截图并写一行表单 pass elif key == ord('q'): break

每帧都重新读一遍轨迹栏,这是参数能「实时生效」的原因,也是后面跨图调参残留问题的根源,第 5 章会细说。waitKey(1)里的 1 是毫秒,给 0 会阻塞到按键,视频流就卡住了;& 0xFF是为了消掉某些平台上返回值的高位,不写这一句在部分环境里按s没反应。

3. 预处理链路:高斯模糊、Canny 双阈值与形态学闭运算的先后顺序

3.1 preProcessing 的固定顺序

预处理函数看着只有几行,但顺序换了结果差很多,这条链路基本是玄学重灾区。项目里的实现是灰度化 → 高斯模糊 → Canny → 膨胀 → 闭运算,每一步都为下一步服务。

def preProcessing(img): imgGray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 核 5x5,sigmaX 显式给 3,模糊强度不随核大小漂移 imgBlur = cv2.GaussianBlur(imgGray, (5, 5), 3) t1 = cv2.getTrackbarPos("Threshold1", WINDOW) t2 = cv2.getTrackbarPos("Threshold2", WINDOW) imgCanny = cv2.Canny(imgBlur, t1, t2) kernel = np.ones((3, 3), np.uint8) # 先膨胀接断口,再闭运算填内部小洞 imgDil = cv2.dilate(imgCanny, kernel, iterations=1) imgClose = cv2.morphologyEx(imgDil, cv2.MORPH_CLOSE, kernel, iterations=1) return imgClose

灰度化丢掉颜色,是因为计数场景里判据通常是形状和面积,不是颜色。高斯模糊的sigmaX给 0 时 OpenCV 会按核大小自动推算,这里显式写 3 是为了让模糊强度固定下来,换核大小的时候不至于一起漂。Canny 的双阈值分工是:低阈值决定边缘的延续,高阈值决定边缘的起点,两者比值落在 1:2 到 1:3 之间比较稳。膨胀一次把 Canny 断开的边缘接上,闭运算填掉物体内部的小空洞,避免一个物体被拆成两个轮廓。

参数上,(5, 5)和sigmaX=3是一组匹配值,核半径约 2,sigma 取 1.5 到 3 都合理。kernel用 3×3 是起点,如果物体边缘在 640 宽度下断口超过 6 像素,可以换 5×5,但迭代次数保持 1。迭代次数加到 2 以上,相邻物体之间的窄缝会被填平,两个物体粘成一个,计数直接少一半。

3.2 双阈值怎么调:先定高阈值,再放低阈值

调参顺序很关键,反过来调会一直在噪声里打转。我的习惯是先用 Single-image 模式加载一张有代表性的图,按下面的步骤走:

  1. 把Threshold2拉到 150 以上,观察边缘是否只剩物体外轮廓,背景纹理应该基本消失。
  2. 逐步降Threshold2,直到物体轮廓连续但背景还没起大片噪点,记下这个值。
  3. 把Threshold1从 30 开始往上加,观察轮廓是否闭合成环,闭合即可停。
  4. 切换到 Multi-image 跑 20 到 30 张,看计数波动,波动大的图回单图模式复查。

不同场景的起点值差别不小,可以拿这张表做参考,再按实际画面微调:

场景Threshold1Threshold2说明
室内白底小零件2570对比度高,阈值可以压低
逆光传送带45120背景亮,高阈值要抬高抑制反光
低对比度泡沫件1550边缘弱,双阈值都要降
深色台面金属件3590反光点多,配合面积过滤

只调一个阈值的后果很明确:Threshold2过高,边缘断裂,轮廓面积偏小,会被minArea整片筛掉;Threshold1过低,背景噪点连成片,闭运算之后糊成一个大块,计数虚高。

3.3 形态学核尺寸与物体间隙的关系

膨胀和闭运算都用同一个 3×3 核,看起来省事,但这里有个硬约束:核的直径必须小于相邻物体的最小间隙。3×3 核膨胀一次,物体边缘向外扩约 1 像素,两个物体间距小于 2 像素时就会粘上。如果现场零件挨得很近,要么把核降到 2×2,要么在预处理前先做一次腐蚀把间隙拉开,代价是弱边缘会丢。

闭运算的作用和膨胀不同,它先膨胀后腐蚀,填的是轮廓内部的小孔,对外轮廓的扩张比纯膨胀小。顺序不能反:先闭运算再膨胀,孔是填了,但后续膨胀仍会把物体撑大;先膨胀再闭运算,断口接上之后闭运算只处理内部,外形更接近真实边界。

注意:形态学操作对二值图有效,Canny输出是单通道 8 位图,值只有 0 和 255,可以直接进dilate。如果中间插了一步归一化或者浮点转换,形态学函数会报类型错误。

3.4 拍照脚本与 ROI 遮罩

图像检测模式的素材不是随手拍的,采集阶段就要把背景干扰压下去。data_collection.py里用了三个辅助函数:Bbox_img在画面中央画红框,maskBbox对框外做高斯模糊,Adjusted_image调亮度。

def Bbox_img(img, bbox_scale=0.5, bbox_color=(0, 0, 255)): h, w = img.shape[:2] bw, bh = int(w * bbox_scale), int(h * bbox_scale) x1, y1 = (w - bw) // 2, (h - bh) // 2 cv2.rectangle(img, (x1, y1), (x1 + bw, y1 + bh), bbox_color, 2) return img, (x1, y1, bw, bh) def maskBbox(img, position): x, y, w, h = position mask = np.zeros(img.shape[:2], np.uint8) mask[y:y + h, x:x + w] = 255 # 大核模糊,目的是让框外失去高频信息,不是去噪 blur = cv2.GaussianBlur(img, (21, 21), 0) return np.where(mask[..., None] == 255, img, blur) def Adjusted_image(img, brightness_factor=1.5): # 查表比逐像素乘法快,uint8 溢出由 min 截断 table = np.array([min(255, int(i * brightness_factor)) for i in range(256)], np.uint8) return cv2.LUT(img, table)

Bbox_img返回的元组必须原样传给maskBbox,否则掩膜和红框错位,框外该糊的地方没糊。bbox_scale取 0.5 表示框占画面一半,小零件调到 0.2 到 0.3,让物体尽量填满框。GaussianBlur的 21×21 是刻意给的大核,作用是让框外的纹理彻底消失,不是常规去噪,核小了背景边缘还会残留。

Adjusted_image用cv2.LUT而不是img * 1.5,差别在视频流里很明显:后者每帧都要产生一个浮点临时数组再转回uint8,两次内存拷贝,帧率会掉。brightness_factor=1.5是经验值,画面偏暗时给 1.8,过曝时降到 0.8,靠第二个轨迹栏实时调。

4. 从轮廓到计数:findContours 封装与最大边界框的三个参数

4.1 封装函数的签名与参数含义

原生的cv2.findContours只负责找轮廓,筛面积、筛形状、画框、排序都得自己补。tally.py里把它封成一个函数,签名带了六个参数,每个都对应一类现场问题。

def findContours(img, imgPre, minArea=1000, sort=True, filter=0, c=(255, 0, 0)): conFound = [] imgContours = img.copy() # OpenCV 4.x 返回两个值 contours, _ = cv2.findContours(imgPre, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) for cnt in contours: area = cv2.contourArea(cnt) if area < minArea: # 粗筛噪点 continue peri = cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) # 多边形近似 if filter > 0 and len(approx) != filter: # 按边数筛形状 continue x, y, w, h = cv2.boundingRect(approx) cx, cy = x + w // 2, y + h // 2 if 3000 < area < 16000: # 有效面积区间才画框 cv2.rectangle(imgContours, (x, y), (x + w, y + h), c, 2) cv2.circle(imgContours, (cx, cy), 5, (0, 255, 0), -1) conFound.append({"cnt": cnt, "area": area, "bbox": (x, y, w, h), "center": (cx, cy)}) if sort: conFound = sorted(conFound, key=lambda x: x["area"], reverse=True) return imgContours, conFound, len(conFound)

逻辑上有几处取舍值得说。RETR_EXTERNAL只取最外层轮廓,大物体内部的小孔不会被当成独立物体,这是轮廓计数不虚高的前提。CHAIN_APPROX_NONE存下轮廓上的所有点,内存占用大,但approxPolyDP的精度依赖点的密度,用CHAIN_APPROX_SIMPLE会丢掉直线段上的中间点,近似结果偏向多边形顶点。

0.02 * peri是近似精度,值越小越贴合原始轮廓,越大越接近规则多边形。filter参数按边数筛形状,filter=4只留四边形,适合方形容器;圆形物体边数在近似后不稳定,别用这个参数,会漏计。boundingRect传入的是近似后的approx而不是原始cnt,目的是让毛刺不把外接框撑大。

4.2 为什么从轮廓计数改成 boundingRect 计数

初版是用len(conFound)直接当数量,也就是数轮廓个数。实测下来,无论怎么调轨迹栏,误差都压不住:一个物体的边缘被 Canny 断成两段,就数成两个;两个物体在二值图上粘连,就数成一个;背景反光形成的小闭合环,面积过了minArea又被算进去。这些误差不是调参能消掉的,因为判据本身就不稳。

改成以boundingRect为单位计数后,情况好转:每个轮廓只产生一个外接矩形,计数以矩形为单位,至少不会因为轮廓破碎而一个物体出两个框。边界也很清楚——两个物体粘连时,外接矩形会把它们框成一个大矩形,仍然计成 1 个。所以这个方案的准确率上限由预处理决定,核尺寸和阈值调不好,后面怎么改计数逻辑都救不回来。

改进后的写法会把面积判据换成外接矩形面积,再加一道长宽比过滤:

valid = [] for cnt in contours: area = cv2.contourArea(cnt) if area < minArea: continue x, y, w, h = cv2.boundingRect(cnt) rect_area = w * h # 用外接矩形面积做二次筛选,对边缘毛刺不敏感 if not (3000 < rect_area < 16000): continue # 长宽比过滤,排除条状反光和细长噪声 aspect = w / float(h) if aspect < 0.3 or aspect > 3.0: continue valid.append({"bbox": (x, y, w, h), "area": rect_area}) total = len(valid)

contourArea对断口敏感,rect_area对外形毛刺不敏感,两个都用一遍相当于双重过滤。长宽比这一道是针对传送带上的条状反光加的,正常零件长宽比落在 0.5 到 2.0 之间,设 0.3 和 3.0 是留了余量。

4.3 面积阈值区间的标定方法

3000 到 16000 这个区间不是拍脑袋定的,是从数据里打出来的。步骤可以照抄:

  1. 用 Single-image 模式加载一张典型图,把MinArea轨迹栏拉到 0,让所有轮廓都进列表。
  2. 在循环里临时打印每个轮廓的contourArea和w*h,观察目标物体的面积分布。
  3. 取目标物体面积的 10 分位做下限、90 分位做上限,上下限比值控制在 3 到 5 倍。
  4. 用 Multi-image 跑 20 到 30 张,人工核对计数,误差超过 2 的图单独拎出来复查。
  5. 把区间写进代码或者配置文件,轨迹栏上限同步调整。

临时打印的代码片段:

# 标定阶段打开,跑完删掉 for i, cnt in enumerate(contours): a = cv2.contourArea(cnt) x, y, w, h = cv2.boundingRect(cnt) print(f"#{i} contour={a:.0f} rect={w*h} " f"bbox=({x},{y},{w},{h}) aspect={w/h:.2f}")

区间太窄是常见误用:同一批零件因为距离镜头远近不同,面积能差一倍,阈值卡在 5000 到 6000 之间就会漏掉远处的小件。反过来区间太宽,噪声也放进来,计数虚高。如果物体尺度变化确实很大,面积阈值就不是好判据,得换轮廓周长,或者干脆上检测模型。

4.4 三种模式的调度

控制台入口把三种模式串起来,实时模式走摄像头,多图模式走文件夹批量,单图模式是多图模式的子集,用来先调参再批量跑。

def main(): print("1) Real-time 2) Multi-image 3) Single-image") mode = input("select: ").strip() if mode == "1": cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) run_realtime(cap) elif mode in ("2", "3"): folder = input("image folder: ").strip() paths = readPath(folder) if mode == "3": paths = paths[:1] # 单图只取第一张 run_batch(paths)

Single-image存在的意义是省时间:批量跑 30 张要几分钟,单图调参只要几十秒,调好之后再批量验证,避免跑完一批才发现阈值全错。readPath返回的路径列表要按文件名排序,否则多图模式的表单顺序和人工核对顺序对不上,排查时很痛苦。

5. 避坑与排查:五个把计数结果带偏的细节

5.1 API 版本差异:findContours 的返回值个数

现象:代码在别人的机器上跑得好好的,换一台就报ValueError: too many values to unpack (expected 2),指向contours, hierarchy = cv2.findContours(...)这一行。

原因:OpenCV 3.x 的findContours返回三个值,第一个是处理后的图像,后两个才是轮廓和层级;4.x 把图像那个返回值去掉了,只剩两个。版本不同,解包个数就不同。

解决:不想钉版本就写兼容写法,用负索引取轮廓,两个版本都能对上:

found = cv2.findContours(imgPre, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) contours = found[-2] # 4.x 取 contours,3.x 也取 contours

found[-2]在 4.x 里是第一个元素,在 3.x 里是第二个元素,正好都是轮廓列表。另一处版本差异是cv2.CAP_PROP_FRAME_WIDTH这类常量,3.x 里叫CV_CAP_PROP_FRAME_WIDTH,新代码统一用带CAP_前缀的写法。

5.2 窗口与参数:轨迹栏的三个典型翻车点

现象一:启动直接抛cv2.error: OpenCV(4.9.0) ... window not found。

原因:createTrackbar写在namedWindow之前。轨迹栏必须挂到一个已经存在的窗口上,窗口没建,挂载就失败。

解决:把namedWindow提到最前面,所有createTrackbar跟在后面。切换图片时不要重复调namedWindow,用setTrackbarPos重置滑块位置就行。

现象二:上一张图把Threshold1调到 60,换一张图后没动滑块,计数全错。

原因:轨迹栏是窗口级状态,不是每张图独立。批量模式下窗口只建一次,滑块值会一直沿用。

解决:单图模式每次加载新图后,显式把参数重置到默认值:

def reset_trackbars(defaults: dict): for name, val in defaults.items(): cv2.setTrackbarPos(name, WINDOW, val) reset_trackbars({"Threshold1": 30, "Threshold2": 80, "MinArea": 1000})

更稳的做法是把每张图调好的最优参数连同文件名一起记进表单,下次遇到同类图直接查表设参,不用重新试。

现象三:按s没反应,或者要连按好几下才触发一次保存。

原因:cv2.waitKey(1)的返回值在某些平台带高位,直接和ord('s')比较可能不相等;另一种情况是waitKey写在imshow之前,窗口还没刷新,按键事件丢了。

解决:判断写成cv2.waitKey(1) & 0xFF == ord('s'),并且保证imshow在前、waitKey在后。视频流里waitKey的参数给 1 到 10 毫秒,给 0 会阻塞。

5.3 数据落盘:中文路径与 Excel 追加

现象一:readPath返回的路径明明存在,cv2.imread读出来全是None,后面cvtColor报断言失败。

原因:cv2.imread底层用 C 字符串打开文件,路径里有中文或特殊字符时打不开,而且不抛异常,静默返回None。

解决:用numpy读字节流再解码,写文件同理:

def imread_unicode(path): data = np.fromfile(path, dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) def imwrite_unicode(path, img): ext = os.path.splitext(path)[1] ok, buf = cv2.imencode(ext, img) if ok: buf.tofile(path) return ok

imdecode的第二个参数用IMREAD_COLOR保持三通道,和后续处理链一致;如果用IMREAD_UNCHANGED,带透明通道的 PNG 会变成四通道,cvtColor的转换码要跟着改。

现象二:第二次按s保存,打开表格发现只剩一行,之前的数据没了。

原因:DataFrame.to_excel默认mode='w',每次写都重建文件。哪怕传的是已有文件路径,也是覆盖。

解决:要么先读旧表再concat,要么直接用openpyxl追加行:

from openpyxl import load_workbook, Workbook def append_row(xlsx_path, row: dict): if os.path.exists(xlsx_path): wb = load_workbook(xlsx_path) ws = wb.active else: wb = Workbook() ws = wb.active ws.append(list(row.keys())) # 首行写表头 ws.append(list(row.values())) wb.save(xlsx_path)

字典的键顺序在 Python 3.7 以后是插入序,表头和数据的列能对上。追加前最好用绝对路径查一遍已有表里的文件名,避免同一张图按两次s写两行。这一点在批量模式里尤其重要,重复行会让后续统计的数量翻倍。

6. 进阶用法:把表单反哺成数据集标注与参数回归验证

表单写顺了之后,它的价值就不只是「记录一下」,而是变成一份可回归的验证集。命名规则里如果带上数量,比如<类别>_<日期>_<序号>_<数量>.jpg,文件名本身就是弱标注,不用再手工标一遍。

import pandas as pd df = pd.read_excel("records.xlsx") # 从文件名尾部解析数量,例如 part_20230618_001_12.jpg df["label"] = df["filename"].str.extract(r"_(\d+)\.jpg$").astype(int) df["err"] = (df["count"] - df["label"]).abs() print(df.groupby("label")["err"].agg(["mean", "max", "count"])) hard = df[df["err"] > 2]

err的均值看整体偏移,max看最坏情况,count看样本量够不够。均值在 0.5 以内、最大值不超过 2,说明当前参数在这一批图上基本可用;hard里拎出来的图,回去用 Single-image 模式逐张看面积分布和阈值,调完再批量跑一遍,形成「调参 → 批量 → 回归」的闭环。这一步比盲目拖滑块有效得多,因为每次改动都有量化反馈。

再往下走,可以把hard里的图当成失败集,单独统计它们的共同特征:是面积偏小被minArea筛掉了,还是长宽比超限被过滤了。如果是前者,说明面积区间下限要往下放;如果是后者,说明长宽比阈值太紧。参数和失败样本对上号之后,下一次换场景就有据可依,不用从零试。

从那以后我每次动面积阈值或者形态学核尺寸,都强制先用 Single-image 把面积分布打印一遍,再跑 20 张批量看err分布,最后才把参数写死。省下来的返工时间,比调参本身多得多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进