简介:面向计算机视觉与烟草质检场景,这份基于Python和OpenCV实现的烟丝检测分割资源,适合有基础图像处理知识、希望落地目标检测与分割任务的开发者。项目围绕烟丝图像读入、颜色空间转换、滤波去噪、边缘检测、轮廓提取与形态学处理展开,并通过轮廓面积与周长计算进一步分析烟丝宽度等属性。压缩包共12个文件,以main.py主脚本为核心,配套train.jpg、test.jpg等图像样本和多个xml配置文件,整体大小13.32MB,结构简洁便于直接运行与二次修改。已有591人学习使用,可作为烟丝检测分割任务的完整参考。压缩包内不仅给出了可直接执行的Python代码,还保留了项目配置文件与测试图像,能帮助读者快速理解OpenCV各图像处理环节的实际衔接方式,并可在此基础上调整参数或替换数据,扩展到其他细长目标的分割与测量场景,对烟草工业质量控制、生产自动化的算法验证具有实用价值。
1. 烟丝检测分割到底在解什么题:从产线质检到实验室分析的共同刚需
烟丝检测分割是烟草行业视觉项目里典型的“看着简单、做起来碎”的活。烟丝颜色从金黄到深褐,表面带着油光,碎屑、烟梗、梗签混在一起,背景稍有阴影阈值就崩。基于Python和OpenCV做这套分割,核心不是堆网络,而是把颜色空间选对、形态学参数调顺、连通域筛稳。这篇文章直接给一套能跟着跑的流水线,从图像采集规范到预处理、阈值分割、轮廓筛选,再到用骨架提取统计长度分布,适合做填充值检测、含梗率分析或产线异物识别的工程师,也适合刚入门OpenCV图像处理项目、想找一个完整实战案例练手的人。所有代码都用Python写,OpenCV版本按4.x说明,老版本接口差异会在避坑章节单独讲。
2. 烟丝图像的采集与预处理:颜色空间选不对,后面全是白费
分割能不能做干净,七成在图像质量,三成在算法。很多烟丝分割项目翻车,不是阈值不会调,而是采集端没控制好变量。烟丝和烟梗的颜色范围很宽,还带油光,如果背景、光源、分辨率三个基础条件不定死,后面所有参数都只能靠“玄学”调。所以先讲采集规范,再讲颜色空间选择,最后给一套可以直接跑的预处理代码。
2.1 成像环境与采集规范:分辨率、背景、光源怎么定
常见做法是把烟丝样品平铺在振动盘或透明玻璃板上,让烟丝尽量单层散开,避免多层堆叠导致的粘连。背景我一般用深蓝色哑光板或者黑色绒布,因为烟丝是黄褐色系,和深蓝背景在色相上天然分离,比白色背景好处理得多。白色背景反光强,烟丝边缘会糊进背景里,后期要用形态学补洞,增加不少工作量。
光源建议用两个条形LED灯从左右45度打光,不要用单顶灯。单顶灯会在烟丝表面形成大面积镜面反射,尤其油光足的烟丝,高光区域亮度接近背景,阈值分割时直接内部掏空。45度侧光能压掉大部分镜面反射,同时把烟丝的纹理细节照出来,后续检测长度分布时更稳。
分辨率不是越高越好。按烟丝平均直径2到3毫米估算,单根烟丝宽度至少占20个像素,再考虑烟丝长度统计精度,成像分辨率用每毫米8到12像素比较合适。一套500万像素相机拍A4纸大小的视野,已经完全够用。分辨率过高,单张图几十MB,处理速度慢,产线上根本跑不动。
采集规范这边还有一个容易忽略的点:烟丝含水率。刚开包的烟丝和暴露在空气中半小时后的烟丝,颜色差异肉眼可见。做检测分割前,先把样品在恒温恒湿环境下放置一段时间,让含水率稳定下来,否则同一个阈值参数在不同批次样品上表现天差地别,排查起来很费劲。
2.2 颜色空间对比:为什么LAB比HSV更适合烟丝与背景分离
OpenCV默认读图是BGR顺序,很多人习惯直接转HSV,因为HSV在物体检测里特别好用。但烟丝分割这个场景,HSV反而不是最优解。烟丝颜色分布在橙色到红褐色区域,H通道集中在10到30,S通道受油光影响波动大,一旦有高光,S值骤降,同一根烟丝在阈值后容易断成几截。
LAB颜色空间更适合这个问题。L通道表示亮度,不包含颜色信息,适合做基础分割;a通道从绿到红,烟丝的黄褐色在a通道上有明显响应,而背景的蓝色在a通道上是负值,天然拉开距离。实测下来,直接用a通道做阈值,比用HSV的S通道稳定很多,尤其在烟丝油光重、背景深浅不一的场景下。
如果背景不是蓝色而是黑色,L通道反而好用,因为烟丝和黑背景的亮度差很大。这时不要用灰度图直接阈值,而是先做CLAHE对比度增强,把暗部细节提出来,再阈值。灰度图直接阈值的问题在于光照不均时,图像四角偏暗,固定阈值会把边缘烟丝切掉。这里可以写一小段采样分析代码,帮你判断当前图像里烟丝像素和背景像素的可分性。
import cv2 import numpy as np img = cv2.imread("tobacco_sample.jpg") # 读入烟丝图像 lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) # 转换到LAB空间 l, a, b = cv2.split(lab) # 分离三个通道 # 人工框选一块纯烟丝区域和一块纯背景区域 # 这里用OpenCV的selectROI交互框选,跑起来后自己画矩形 tobacco_roi = cv2.selectROI("select tobacco", img) bg_roi = cv2.selectROI("select background", img) cv2.destroyAllWindows() def roi_mean_std(channel, roi): x, y, w, h = roi patch = channel[y:y+h, x:x+w] return patch.mean(), patch.std() for name, roi in [("tobacco", tobacco_roi), ("bg", bg_roi)]: for ch_name, ch in [("L", l), ("a", a), ("b", b)]: mean, std = roi_mean_std(ch, roi) print(f"{name} {ch_name} channel: mean={mean:.1f}, std={std:.1f}")这段代码交互式框选烟丝和背景区域,输出每个通道的均值和标准差。判读标准很直接:两个类别的均值差越大越好,标准差越小越好。如果你发现烟丝和背景在某个通道上均值差不到15,说明当前采集条件下这个通道不适合做分割,需要换通道或者调整光源。这个分析过程只花两分钟,能省下后面调半天参数的力气。
2.3 预处理流水线实现:滤波、灰度化、对比度增强的完整代码
确认了颜色通道之后,预处理就固定下来。以蓝色背景、LAB空间为例,我一般走四步:读取图像、转LAB取L通道或a通道、CLAHE增强、轻高斯滤波。这一步目的是让目标区域边界清晰,同时抑制噪声,避免后续阈值分割出现零零碎碎的小点。
import cv2 import numpy as np def preprocess_tobacco(img, use_channel="L", clip_limit=2.0, gaussian_k=5): """ 烟丝图像预处理 use_channel: "L" 或 "a", 根据采样分析结果选择 clip_limit: CLAHE对比度限制, 越大增强越强, 一般2.0~3.0 gaussian_k: 高斯核大小, 必须为奇数 """ lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) channel = l if use_channel == "L" else a # CLAHE只支持单通道, 直接把选中的通道传进去 clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(8, 8)) enhanced = clahe.apply(channel) # 高斯滤波, 核越大越模糊, 5x5在烟丝场景下比较平衡 blurred = cv2.GaussianBlur(enhanced, (gaussian_k, gaussian_k), 0) return blurred img = cv2.imread("tobacco_sample.jpg") processed = preprocess_tobacco(img, use_channel="L") cv2.imwrite("tobacco_preprocessed.png", processed)CLAHHE的clipLimit参数控制对比度增强强度。调得太大会把烟丝表面的纹理噪声也放大,反而干扰分割;调太小则增强效果不明显。实际项目里我习惯先设2.0,看直方图再微调。tileGridSize一般不动,8x8是OpenCV官方默认值,对这个场景足够。高斯核5x5是起点,如果发现阈值后有细碎噪声,可以升到7x7,但不要超过9x9,否则烟丝边缘会变钝,长度统计时会整体缩短。
2.4 预处理参数速查表:不同图像条件下怎么配
| 图像条件 | 推荐通道 | CLAHE clipLimit | 高斯核 | 说明 |
|---|---|---|---|---|
| 蓝色背景、侧光均匀 | a通道 | 1.5~2.0 | 3x3 | 色相分离最好,增强不用太狠 |
| 黑色背景、亮度充足 | L通道 | 2.5~3.0 | 5x5 | 亮度差大,主要防高光干扰 |
| 光照不均、四角偏暗 | L通道 | 3.0~3.5 | 7x7 | 先做光照校正再看效果 |
| 反光严重、烟丝油光大 | a通道 | 1.5 | 3x3 | 不要用L通道,高光区会掏空烟丝 |
这张表不是万能公式,但覆盖了烟丝场景八九成的情况。快速试一遍这些组合,然后用后面要讲的阈值分割对比一下,基本能确定当前采集系统的最佳预处理路径。预处理做到位,分割环节才有稳定的输入。
3. 核心分割流程:从阈值到连通域的参数化实现
预处理输出一张边界清晰、背景干净的单通道图,接下来进入分割核心。烟丝分割和那种“检测一个明显目标”的任务不一样,图里可能有几十根烟丝,互相交叉、粘连,还有碎屑和烟梗。如果只用一个全局阈值,结果通常惨不忍睹。这一章按阈值、形态学、连通域三步走,每一步都给出参数化代码和调节建议。
3.1 阈值策略:OTSU、固定阈值与自适应阈值的适用边界
阈值分割看起来简单,选错方法就翻车。固定阈值适合光照恒定、背景稳定的产线场景,但前提是你有耐心反复试值。OTSU是大津法,自动找全局最优阈值,对烟丝这种双峰明显的直方图效果不错。自适应阈值适合光照不均匀的图像,但它按局部区域计算阈值,对烟丝这种内部纹理丰富的目标容易产生噪声。
我的经验是先用OTSU跑一遍,看结果再决定要不要换自适应。OTSU对蓝色背景的烟丝图通常一次就能拿下一个不错的初分割,后续用形态学修一修就能用。如果OTSU结果里背景碎点太多,说明预处理不够干净,回头调CLAHHE或者换通道,不要死磕阈值本身。
import cv2 import numpy as np processed = cv2.imread("tobacco_preprocessed.png", cv2.IMREAD_GRAYSCALE) # 方法一: OTSU自动阈值 thresh_val, mask_otsu = cv2.threshold( processed, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU ) # 方法二: 自适应阈值, 适合光照不均的图像 mask_adaptive = cv2.adaptiveThreshold( processed, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize=51, # 局部邻域大小, 越大越平滑, 但太小会丢失边缘 C=5 # 常数, 从局部均值减去, 越大分割结果越保守 ) cv2.imwrite("mask_otsu.png", mask_otsu) cv2.imwrite("mask_adaptive.png", mask_adaptive)OTSU不需要调参,但有个前提假设:图像直方图是双峰的。烟丝和背景颜色对比明显时成立。自适应阈值的blockSize和C两个参数需要重点关注。blockSize推荐在41到61之间,烟丝较细时用较小的值,能保住细枝;背景光照变化大时用较大值,防止背景被切开。C值控制阈值偏置,默认5起步,如果发现烟丝被切碎,降低C值到3,如果背景噪声多,提高C值到7或9。
3.2 形态学操作:开运算去孤立噪点,闭运算连接断裂
二值化之后,最常见的两个问题:一是背景里残留细小的亮噪声点,二是烟丝表面因为高光或纹理出现细小的黑洞。黑洞不补,连通域分析时一根烟丝会被拆成好几段。形态学操作为此而生,开运算先腐蚀再膨胀,去掉孤立小白点;闭运算先膨胀再腐蚀,填补内部小黑洞。
核的大小在这里很讲究。核太小不起作用,核太大把相邻烟丝糊在一起。烟丝宽度约20像素的场景下,开运算用3x3椭圆核,闭运算用5x5椭圆核,是比较稳的起点。如果烟丝细碎,可以只做开运算不做闭运算,因为闭运算也可能把本就靠近的两根烟丝连起来。
import cv2 import numpy as np mask = cv2.imread("mask_otsu.png", cv2.IMREAD_GRAYSCALE) # 开运算: 去背景噪点 kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) mask_open = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open) # 闭运算: 填补烟丝内部小洞 kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask_close = cv2.morphologyEx(mask_open, cv2.MORPH_CLOSE, kernel_close) cv2.imwrite("mask_clean.png", mask_close)选择椭圆核而不是矩形核,是因为烟丝是长条状物体,椭圆核在保持方向性的同时不会像矩形核那样产生直角棱角。用矩形核处理过的mask边界很生硬,后期如果要做外接矩形拟合,会发现边缘锯齿明显。椭圆核的代价是计算稍慢,但在静态图像处理项目里完全可以忽略。
3.3 连通域分析与轮廓筛选:用面积、长宽比、填充度把烟丝和碎屑分开
mask干净之后,就要把“看起来像烟丝”的目标从“看起来像碎屑”的目标里筛出来。这个环节是整个分割流程里最能体现经验的地方。烟丝的特点是长条状,长宽比通常大于2;碎屑和梗签则是近圆形或方形。再配合面积阈值和填充度,就能把绝大多数干扰物滤掉。
连通域分析和轮廓提取都可以做。连通域分析速度快,直接给出面积和包围盒;轮廓提取可以进一步做最小外接矩形,拿到更精准的旋转角度和长宽。我的建议是两者结合:连通域做初筛,留下的目标再提取轮廓做精筛。
import cv2 import numpy as np mask = cv2.imread("mask_clean.png", cv2.IMREAD_GRAYSCALE) # 连通域分析, 比findContours快很多 num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats( mask, connectivity=8 ) valid_boxes = [] for i in range(1, num_labels): # 0是背景, 跳过 x, y, w, h, area = stats[i] # 面积筛选: 小于此面积的是碎屑, 大于此面积的是团块/杂质 if area < 150 or area > 20000: continue # 长宽比筛选: 烟丝长宽比一般大于2 aspect_ratio = max(w, h) / max(1, min(w, h)) if aspect_ratio < 2.0: continue # 填充度筛选: 烟丝是实心条状, 填充度太低说明轮廓不规则 box_area = w * h fill_ratio = area / box_area if fill_ratio < 0.35: continue valid_boxes.append((x, y, w, h)) print(f"筛选后保留目标数: {len(valid_boxes)}")三个筛选条件不是拍脑袋定的。面积下限150像素,对应直径2毫米、长度2毫米的小碎屑,低于这个值的在大多数烟丝检测场景里不值得关注。面积上限20000像素,对应一根完整长烟丝,如果样品里有烟团,面积会远大于这个值,属于异常物,需要单独告警。长宽比2.0是烟丝和碎屑的分界线,但要注意弯曲的烟丝外接矩形长宽比可能只有1.5,这时候可以降到1.5,代价是碎屑误入率升高。填充度0.35表示目标区域里实际烟丝像素占包围盒的35%,弯曲烟丝和枝状烟梗的填充度比较低,直烟丝通常能到0.6以上。
3.4 分割参数调节建议:跑通后怎么微调
| 参数 | 初始值 | 调节方向 | 判断依据 |
|---|---|---|---|
| 面积下限 | 150 | 碎屑增多时调大 | 残留小点明显 |
| 面积上限 | 20000 | 烟团漏检时调小 | 烟团被分进目标 |
| 长宽比 | 2.0 | 弯曲烟丝被滤掉时调低 | 直烟丝误删 |
| 填充度 | 0.35 | 碎屑混入时调高 | 圆形异物残留 |
| 闭运算核 | 5x5 | 烟丝断裂变多时调大 | 一根烟丝分成多段 |
调参时每次只动一个参数,改完重新跑一遍,用可视化结果对比。不要同时调三个以上参数,否则出了问题你不知道是哪个改坏的。这是一个通用的原则,在烟丝检测这种样本形态差异大的项目里格外重要。
4. 烟丝分割避坑指南:五个常见翻车现场与排查方法
再稳的流程也会遇到奇怪的问题。这一章把实际项目里最常踩的五个坑列出来,每条按现象、原因、解决方式写清楚。有些坑不是算法问题,而是开发环境和OpenCV版本导致的,这类问题最浪费时间,建议直接对照排查。
4.1 翻车现场一:背景阴影被当成烟丝,区域越割越大
现象:分割结果里出现大片连接成块的区域,形状完全不像是烟丝,而且随着光照变化时有时无。原因:深色背景上如果有不均匀的阴影,L通道的亮度分布被破坏,OTSU的全局阈值会把阴影边缘误判为目标。解决:换用a通道做分割。我在2.2节的采样分析就是为了判断当前通道是否有效,当L通道不可分时,a通道几乎总能救回来。如果必须用L通道,先做顶帽变换扣除背景阴影,再用OTSU。
4.2 翻车现场二:烟丝油光反光,阈值后内部出现空洞
现象:阈值分割后的烟丝区域中间有大块黑色空洞,本应是一根完整的烟丝,结果断成两截。原因:烟丝表面油光在LED灯下产生镜面反射,高光区域亮度接近白色背景,被阈值判定为背景。解决:优先换用a通道,因为a通道受亮度影响小。如果高光区域仍然存在,把闭运算核从5x5提到7x7,能填补大部分空洞。我见过最严重的情况是闭运算都填不住,后来在光源前加了一层漫反射板,高光立刻减弱。
4.3 翻车现场三:粘连烟丝被合并成一个轮廓,长度统计严重偏大
现象:两根烟丝交叉叠在一起,分割结果只有一个连通域,统计长度时被当作一根超长烟丝。原因:形态学闭运算核过大,或者烟丝本身堆叠,把邻近目标连在了一起。解决:先检查闭运算核,如果从5x5换到3x3就解决了,说明是形态学问题。如果确实交叉堆叠,需要提前用玻璃板压平样品,保证烟丝单层。对于轻微粘连,可以用距离变换配合分水岭做细分割,但分水岭容易过度分割,我不建议默认开启。
# 距离变换 + 分水岭处理轻微粘连烟丝 import cv2 import numpy as np mask = cv2.imread("mask_clean.png", cv2.IMREAD_GRAYSCALE) # 距离变换, 得到每个前景像素到背景的距离 dist = cv2.distanceTransform(mask, cv2.DIST_L2, 5) # 用距离图的局部极大值确定内部种子点 _, sure_fg = cv2.threshold(dist, 0.5 * dist.max(), 255, cv2.THRESH_BINARY) sure_fg = np.uint8(sure_fg) # 背景区域用膨胀确定, 和前景种子点之间留出待分割区 unknown = cv2.subtract(mask, sure_fg) # 标记并做分水岭 _, markers = cv2.connectedComponents(sure_fg) markers = markers + 1 markers[unknown == 255] = 0 markers = cv2.watershed(cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR), markers)分水岭不是万能的。它需要烟丝之间有明确的距离低谷,如果两根烟丝完全平行贴在一起,距离变换区分不出种子点,分水岭会乱切。跑完分水岭后用3.3节的筛选条件再检查一遍目标数,如果分割出大量极细碎块,就是过度分割了,应该退回形态学方案。
4.4 翻车现场四:contourArea()未定义标识符
现象:在OpenCV里调用cv2.contourArea()直接报错,提示“未定义标识符”。原因:项目里同时安装了多个OpenCV包,或者导入的模块名错误。更隐蔽的情况是conda环境里自带的OpenCV版本过老,findContours和contourArea的接口行为不一致。解决:先检查当前环境的版本和执行路径,然后按需重装。
python -c "import cv2; print(cv2.__version__); print(cv2.__file__)"pip uninstall opencv-python opencv-contrib-python -y pip install opencv-contrib-python==4.8.1.78如果你用的是OpenCV 3.x,findContours会返回三个值,新版返回两个值。代码写成contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)在旧版上就会报错。兼容写法是_, contours, _ = cv2.findContours(...),之后再按版本过滤,或者干脆统一升级到OpenCV 4.x省心。另外提醒一句,opencv-contrib-python包含ximgproc等扩展模块,第5章的骨架提取函数需要它,所以建议直接装contrib版本。
4.5 翻车现场五:处理速度慢,产线实时性达不到
现象:单张图处理耗时超过500毫秒,产线节拍要求200毫秒以内。原因:没有控制处理分辨率,整张图从头到尾做完所有操作;形态学核过大,连通域分析用了8连通全图;还有可能调试阶段开了imshow实时显示,拖慢速度。解决:先把输入图像缩放,长边控制在1280像素以内。再有针对性地缩小处理区域,烟丝检测关注的是有效区域,可以先做一次粗定位,裁掉背景空白区域。最后关掉调试窗口,用imwrite保存结果替代imshow。
如果单靠CPU优化还达不到要求,OpenCV支持CUDA加速。安装对应CUDA版本的预编译opencv wheels,注意必须和Python版本、CUDA版本、操作系统严格匹配,否则会出现导入失败或显存报错。小项目通常不需要上CUDA,先做流程优化更实际。我在一个项目里把图像从2000万像素缩到300万处理后,速度直接提升了6倍,分割精度几乎没有下降。
5. 用骨架提取和最小外接矩形统计烟丝长度分布:一个可复用的量化脚本
分割出每根烟丝后,最常问的问题是“这批烟丝的长度分布怎么样”。烟丝长度直接影响填充值和卷制质量。OpenCV的ximgproc模块里有一个thinning函数,能把二值化烟丝细化为单像素骨架,然后统计骨架像素数量乘以像素物理尺寸,就能估算出烟丝长度。这个方法比最小外接矩形对角线更准确,尤其是弯曲烟丝。
import cv2 import numpy as np mask = cv2.imread("mask_clean.png", cv2.IMREAD_GRAYSCALE) # 对单个烟丝区域做骨架提取 # 先拿到所有目标连通域的包围盒和mask num, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8) lengths_mm = [] pixel_size_mm = 0.1 # 根据相机标定结果填, 单位mm/pixel for i in range(1, num): x, y, w, h, _ = stats[i] if w < 10 or h < 10: # 跳过细小碎屑 continue single_mask = np.zeros((h, w), dtype=np.uint8) single_mask[labels[y:y+h, x:x+w] == i] = 255 # 骨架提取, 需要opencv-contrib-python skeleton = cv2.ximgproc.thinning( single_mask, cv2.ximgproc.THINNING_GUOHALL ) skeleton_len = np.count_nonzero(skeleton) length_mm = skeleton_len * pixel_size_mm lengths_mm.append(length_mm) lengths_mm = np.array(lengths_mm) print(f"有效烟丝数量: {len(lengths_mm)}") print(f"平均长度: {lengths_mm.mean():.1f} mm") print(f"长度标准差: {lengths_mm.std():.1f} mm") print(f"长度中位数: {np.median(lengths_mm):.1f} mm")骨架提取对噪声敏感,骨架像素点其实表示的是烟丝中线长度,会在拐弯处产生少量冗余。如果你的样品里弯曲烟丝特别多,统计长度会偏大。这时候可以对骨架先做一次霍夫直线检测,把接近直线的段拆出来再累加,但要注意检测直线的参数要调好,否则短烟丝被忽略。xingneng上,骨架提取对每根烟丝单独做,比全图一次做要慢,但胜在不会把相邻烟丝的骨架连起来,长度统计更可信。
我做烟丝检测分割项目时养成一个习惯:每次调完参数,先把预处理图、mask、筛选结果图都存一份,标注好参数组合和日期,再批量跑数据。这样回头排查问题时,能直接对比是哪一步改坏了结果,而不是靠记忆猜。分割代码不是写一次就完事,烟丝批次一变,参数一定要重新验证。这个方案是一个能落地的起点,跑通后再根据你的产线条件继续调。希望帮到你。
本文还有配套的精品资源,点击获取