
简介这份文档面向计算机视觉入门者与本科课程设计学生主题为基于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、hierarchy4.x 只返回两个contours、hierarchy。如果你从旧环境拷代码过来第一处报错大概率在这里。所以先把版本钉死虚拟环境里装python -m venv venv # Windows venv\Scripts\activate # Linux / macOS source venv/bin/activate pip install opencv-python4.9.0.80 pip install pandas2.1.4 openpyxl3.1.2 numpy1.26.2opencv-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, minAreacv2.getTrackbarPos(MinArea, WINDOW)) cv2.putText(imgBox, fcount{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) # 核 5x5sigmaX 显式给 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, iterations1) imgClose cv2.morphologyEx(imgDil, cv2.MORPH_CLOSE, kernel, iterations1) return imgClose灰度化丢掉颜色是因为计数场景里判据通常是形状和面积不是颜色。高斯模糊的sigmaX给 0 时 OpenCV 会按核大小自动推算这里显式写 3 是为了让模糊强度固定下来换核大小的时候不至于一起漂。Canny 的双阈值分工是低阈值决定边缘的延续高阈值决定边缘的起点两者比值落在 1:2 到 1:3 之间比较稳。膨胀一次把 Canny 断开的边缘接上闭运算填掉物体内部的小空洞避免一个物体被拆成两个轮廓。参数上(5, 5)和sigmaX3是一组匹配值核半径约 2sigma 取 1.5 到 3 都合理。kernel用 3×3 是起点如果物体边缘在 640 宽度下断口超过 6 像素可以换 5×5但迭代次数保持 1。迭代次数加到 2 以上相邻物体之间的窄缝会被填平两个物体粘成一个计数直接少一半。3.2 双阈值怎么调先定高阈值再放低阈值调参顺序很关键反过来调会一直在噪声里打转。我的习惯是先用 Single-image 模式加载一张有代表性的图按下面的步骤走把Threshold2拉到 150 以上观察边缘是否只剩物体外轮廓背景纹理应该基本消失。逐步降Threshold2直到物体轮廓连续但背景还没起大片噪点记下这个值。把Threshold1从 30 开始往上加观察轮廓是否闭合成环闭合即可停。切换到 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_scale0.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_factor1.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_factor1.5是经验值画面偏暗时给 1.8过曝时降到 0.8靠第二个轨迹栏实时调。4. 从轮廓到计数findContours 封装与最大边界框的三个参数4.1 封装函数的签名与参数含义原生的cv2.findContours只负责找轮廓筛面积、筛形状、画框、排序都得自己补。tally.py里把它封成一个函数签名带了六个参数每个都对应一类现场问题。def findContours(img, imgPre, minArea1000, sortTrue, filter0, 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, keylambda x: x[area], reverseTrue) return imgContours, conFound, len(conFound)逻辑上有几处取舍值得说。RETR_EXTERNAL只取最外层轮廓大物体内部的小孔不会被当成独立物体这是轮廓计数不虚高的前提。CHAIN_APPROX_NONE存下轮廓上的所有点内存占用大但approxPolyDP的精度依赖点的密度用CHAIN_APPROX_SIMPLE会丢掉直线段上的中间点近似结果偏向多边形顶点。0.02 * peri是近似精度值越小越贴合原始轮廓越大越接近规则多边形。filter参数按边数筛形状filter4只留四边形适合方形容器圆形物体边数在近似后不稳定别用这个参数会漏计。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 这个区间不是拍脑袋定的是从数据里打出来的。步骤可以照抄用 Single-image 模式加载一张典型图把MinArea轨迹栏拉到 0让所有轮廓都进列表。在循环里临时打印每个轮廓的contourArea和w*h观察目标物体的面积分布。取目标物体面积的 10 分位做下限、90 分位做上限上下限比值控制在 3 到 5 倍。用 Multi-image 跑 20 到 30 张人工核对计数误差超过 2 的图单独拎出来复查。把区间写进代码或者配置文件轨迹栏上限同步调整。临时打印的代码片段# 标定阶段打开跑完删掉 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} fbbox({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 取 contours3.x 也取 contoursfound[-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, dtypenp.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 okimdecode的第二个参数用IMREAD_COLOR保持三通道和后续处理链一致如果用IMREAD_UNCHANGED带透明通道的 PNG 会变成四通道cvtColor的转换码要跟着改。现象二第二次按s保存打开表格发现只剩一行之前的数据没了。原因DataFrame.to_excel默认modew每次写都重建文件。哪怕传的是已有文件路径也是覆盖。解决要么先读旧表再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分布最后才把参数写死。省下来的返工时间比调参本身多得多。希望帮到你。本文还有配套的精品资源点击获取