基于工业机器视觉的瓶盖凸起(鼓包)检测系统
一、实际应用场景描述
场景:某大型饮料灌装企业的PET瓶盖压塑成型后检测工位。该企业日产1.2亿个瓶盖,压塑成型的瓶盖在冷却过程中,因模具温度不均、原料含水率过高或压力波动,易产生内部应力集中,形成肉眼可见的微小凸起(鼓包)。这些鼓包会导致瓶盖密封不严,引发饮料变质和微生物污染风险。
目标:在生产线上部署视觉检测系统,对瓶盖顶部进行360°全方位扫描,实时识别凸起缺陷,剔除不合格品,确保出厂瓶盖密封合格率≥99.99%。
二、引入痛点
- 缺陷特征细微:鼓包高度通常在0.1-0.5mm之间,直径2-5mm,与正常瓶盖表面的注塑纹路(深度0.05-0.15mm)高度相似,传统2D视觉难以区分。
- 表面纹理干扰:PET瓶盖表面存在环形防滑纹和放射状加强筋,纹理起伏易被误判为凸起。
- 检测效率要求极高:产线速度达1200个/分钟,单瓶盖检测时间需≤30ms,且需兼容不同规格瓶盖(直径28mm-45mm)。
- 环境光干扰:车间日光灯频闪(50Hz)及瓶盖反光(PET材料折射率1.57)导致图像出现亮斑和阴影,影响特征提取。
三、核心逻辑讲解
3.1 整体流程
瓶盖定位 → 环形光源触发 → 多角度图像采集 → 图像预处理 → 高度特征提取 → 鼓包判定 → 分拣剔除
3.2 关键技术点
- 主动光照明技术:采用同轴光+环形低角度光的组合照明方案,抑制表面纹理反射,凸显凸起区域的阴影特征。
- 多视角融合检测:通过旋转载物台(步进电机控制,每15°采集一帧,共24帧/圈),解决单一视角遮挡问题。
- 高度特征提取:
- 局部曲率分析:计算瓶盖表面各点的高斯曲率,凸起区域曲率为正值且远大于背景。
- 灰度梯度积分:沿径向方向计算灰度梯度的积分值,凸起区域因高度变化导致积分值突增。
- 动态阈值判定:基于无缺陷瓶盖样本库,采用3σ准则动态生成每个批次的判定阈值,适应原料批次差异。
- 实时性优化:采用多线程并行处理,图像采集与特征计算异步执行,利用NumPy向量化运算加速曲率计算。
四、代码模块化实现
项目结构
cap_inspection/ ├── config/ # 配置文件目录 │ └── settings.yaml # 系统参数配置 ├── src/ # 源代码目录 │ ├── camera.py # 工业相机接口模块 │ ├── lighting_control.py # 光源控制与触发模块 │ ├── preprocessor.py # 图像预处理模块 │ ├── feature_extractor.py # 鼓包特征提取模块 │ ├── defect_detector.py # 缺陷判定模块 │ └── sorter.py # 分拣控制模块 ├── data/ # 数据存储目录 │ ├── normal_samples/ # 无缺陷样本库 │ └── defect_samples/ # 缺陷样本库(用于模型训练/测试) ├── models/ # 预训练模型目录 │ └── curvature_model.pkl # 曲率特征基准模型 ├── utils/ # 工具函数目录 │ ├── logger.py # 日志记录工具 │ └── geometry.py # 几何计算工具 ├── main.py # 主程序入口 └── README.md # 项目说明文档
核心代码实现
- 配置文件 (config/settings.yaml)
camera: device_id: 1 # 相机设备ID resolution: [2048, 2048] # 分辨率(方形,便于旋转拼接) exposure: 8000 # 曝光时间(μs) gain: 12 # 增益值 trigger_mode: "hardware" # 触发模式:硬件触发(与光源同步)
lighting: ring_light_intensity: 80 # 环形光强度(0-100) coaxial_light_intensity: 60 # 同轴光强度 trigger_delay: 0.002 # 光源触发延迟(s),确保光强稳定
preprocessing: gaussian_kernel: 3 # 高斯滤波核大小 clahe_clip_limit: 2.0 # CLAHE对比度限制 clahe_tile_size: 8 # CLAHE网格大小
feature_extraction: curvature_window_size: 15 # 曲率计算窗口大小(像素) radial_gradient_bins: 36 # 径向梯度积分分区数 height_threshold_factor: 3.0 # 高度阈值系数(乘以标准差)
inspection: rotation_steps: 24 # 旋转采集步数(360/24=15°/步) defect_area_min: 20 # 最小缺陷面积(像素²) defect_height_min: 0.08 # 最小缺陷高度(mm,需标定转换) plc_ip: "192.168.1.101" # PLC IP地址 reject_bin: 3 # 次品料道编号 cycle_time_max: 0.03 # 最大检测周期(s)
- 图像预处理模块 (src/preprocessor.py)
import cv2 import numpy as np from typing import Tuple, Optional
class ImagePreprocessor: """图像预处理模块:负责降噪、增强对比度、消除纹理干扰"""
def __init__(self, config: dict):
"""
初始化预处理参数
:param config: 预处理配置字典
"""
self.gaussian_kernel = config.get('gaussian_kernel', 3)
self.clahe_clip_limit = config.get('clahe_clip_limit', 2.0)
self.clahe_tile_size = config.get('clahe_tile_size', 8)
self.clahe = cv2.createCLAHE(
clipLimit=self.clahe_clip_limit,
tileGridSize=(self.clahe_tile_size, self.clahe_tile_size)
)
def remove_texture_noise(self, image: np.ndarray) -> np.ndarray:
"""
基于形态学的纹理噪声去除(针对环形/放射状纹理)
:param image: 输入灰度图像
:return: 纹理抑制后的图像
"""
# 定义结构元素:匹配瓶盖常见纹理方向
kernel_ring = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
kernel_radial = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 7)) # 模拟放射状纹理
# 开运算去除亮纹理
opened = cv2.morphologyEx(image, cv2.MORPH_OPEN, kernel_ring)
# 闭运算去除暗纹理
closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel_radial)
return closed
def enhance_defect_contrast(self, image: np.ndarray) -> np.ndarray:
"""
增强凸起缺陷与背景的对比度
:param image: 输入灰度图像
:return: 增强后的图像
"""
# 高斯滤波降噪
denoised = cv2.GaussianBlur(image, (self.gaussian_kernel, self.gaussian_kernel), 0)
# CLAHE自适应直方图均衡化
enhanced = self.clahe.apply(denoised)
return enhanced
def preprocess_pipeline(self, image: np.ndarray) -> np.ndarray:
"""
完整预处理流水线
:param image: 原始灰度图像
:return: 预处理后的图像
"""
texture_removed = self.remove_texture_noise(image)
enhanced = self.enhance_defect_contrast(texture_removed)
return enhanced
3. 特征提取模块 (src/feature_extractor.py)
import cv2 import numpy as np from scipy.ndimage import gaussian_filter from typing import Dict, List, Tuple import utils.geometry as geom
class FeatureExtractor: """特征提取模块:从预处理图像中提取与凸起高度相关的特征"""
def __init__(self, config: dict, pixel_to_mm: float = 0.02):
"""
初始化特征提取参数
:param config: 特征提取配置
:param pixel_to_mm: 像素到毫米的转换系数(需通过标定获得)
"""
self.window_size = config.get('curvature_window_size', 15)
self.radial_bins = config.get('radial_gradient_bins', 36)
self.pixel_to_mm = pixel_to_mm
self.kernel_size = 2 * (self.window_size // 2) + 1
def calculate_surface_curvature(self, image: np.ndarray) -> np.ndarray:
"""
计算表面高斯曲率(K = k1 * k2,k1/k2为主曲率)
凸起区域K值为正,凹陷为负,平面为0
:param image: 预处理后的灰度图像(代表高度场)
:return: 曲率图
"""
# 计算一阶偏导
dx = gaussian_filter(image, sigma=1, order=[1, 0])
dy = gaussian_filter(image, sigma=1, order=[0, 1])
# 计算二阶偏导
dxx = gaussian_filter(image, sigma=1, order=[2, 0])
dyy = gaussian_filter(image, sigma=1, order=[0, 2])
dxy = gaussian_filter(image, sigma=1, order=[1, 1])
# 计算高斯曲率 K = (dxx * dyy - dxy^2) / (1 + dx^2 + dy^2)^2
denominator = (1 + dx**2 + dy**2) ** 2
curvature = (dxx * dyy - dxy**2) / (denominator + 1e-6) # 避免除零
return curvature
def calculate_radial_gradient_integral(self, image: np.ndarray, center: Tuple[int, int]) -> np.ndarray:
"""
计算径向方向的灰度梯度积分,凸起区域积分值更高
:param image: 预处理后的图像
:param center: 瓶盖圆心坐标
:return: 各径向bin的积分值数组
"""
height, width = image.shape
cx, cy = center
# 生成极坐标系网格
y, x = np.ogrid[:height, :width]
r = np.sqrt((x - cx)**2 + (y - cy)**2)
theta = np.arctan2(y - cy, x - cx)
# 计算径向梯度(沿半径方向的灰度变化率)
gradient_r = np.gradient(image)[0] * np.cos(theta) + np.gradient(image)[1] * np.sin(theta)
# 按角度分区并计算积分
bin_edges = np.linspace(-np.pi, np.pi, self.radial_bins + 1)
integrals = []
for i in range(self.radial_bins):
mask = (theta >= bin_edges[i]) & (theta < bin_edges[i+1])
integral = np.sum(np.abs(gradient_r[mask]))
integrals.append(integral)
return np.array(integrals)
def extract_features(self, image: np.ndarray, center: Tuple[int, int]) -> Dict:
"""
提取完整的鼓包特征集
:param image: 预处理图像
:param center: 瓶盖圆心
:return: 特征字典
"""
features = {}
# 1. 曲率特征
curvature_map = self.calculate_surface_curvature(image)
features['mean_curvature'] = np.mean(curvature_map)
features['max_curvature'] = np.max(curvature_map)
features['curvature_std'] = np.std(curvature_map)
# 提取曲率峰值区域(候选缺陷区域)
peaks = geom.find_local_maxima(curvature_map, min_distance=self.window_size)
features['peak_count'] = len(peaks)
features['peak_curvatures'] = [curvature_map[p] for p in peaks]
# 2. 径向梯度积分特征
radial_integrals = self.calculate_radial_gradient_integral(image, center)
features['radial_integral_mean'] = np.mean(radial_integrals)
features['radial_integral_std'] = np.std(radial_integrals)
features['radial_anomaly_score'] = np.max(radial_integrals) / (np.mean(radial_integrals) + 1e-6)
# 3. 几何特征(候选缺陷区域)
defects = []
for peak in peaks:
# 提取峰值周围的ROI
x1 = max(0, peak[0] - self.window_size//2)
x2 = min(image.shape[0], peak[0] + self.window_size//2 + 1)
y1 = max(0, peak[1] - self.window_size//2)
y2 = min(image.shape[1], peak[1] + self.window_size//2 + 1)
roi = curvature_map[x1:x2, y1:y2]
area = np.sum(roi > features['mean_curvature'] + 2 * features['curvature_std'])
height_mm = np.max(roi) * self.pixel_to_mm # 曲率转高度(简化模型)
defects.append({
'center': peak,
'area_pixels': area,
'height_mm': height_mm,
'curvature': np.max(roi)
})
features['defects'] = defects
return features
4. 缺陷判定模块 (src/defect_detector.py)
import numpy as np from typing import Dict, List, Tuple from sklearn.ensemble import IsolationForest from scipy.stats import zscore
class DefectDetector: """缺陷判定模块:基于特征阈值与机器学习模型判断是否鼓包"""
def __init__(self, config: dict):
"""
初始化判定器
:param config: 检测配置
"""
self.defect_area_min = config.get('defect_area_min', 20)
self.defect_height_min = config.get('defect_height_min', 0.08) # mm
self.height_threshold_factor = config.get('height_threshold_factor', 3.0)
self.rotation_steps = config.get('rotation_steps', 24)
# 初始化无监督学习模型(用于动态阈值调整)
self.isolation_forest = IsolationForest(contamination=0.01, random_state=42)
self.is_trained = False
def train_baseline(self, normal_features_list: List[Dict]):
"""
使用无缺陷样本训练基线模型
:param normal_features_list: 无缺陷特征列表
"""
# 提取所有无缺陷样本的关键特征
X = []
for feat in normal_features_list:
# 使用曲率均值和径向异常分数作为特征
X.append([
feat['mean_curvature'],
feat['max_curvature'],
feat['radial_anomaly_score']
])
X = np.array(X)
if len(X) > 10: # 确保有足够样本
self.isolation_forest.fit(X)
self.is_trained = True
print("基线模型训练完成")
def is_defect(self, features: Dict) -> Tuple[bool, List[Dict]]:
"""
综合判定是否存在鼓包缺陷
:param features: 特征字典
:return: (是否缺陷, 缺陷详情列表)
"""
defects = []
is_defective = False
# 1. 基于规则引擎的初步筛选
for defect in features.get('defects', []):
# 条件1:面积超过阈值
if defect['area_pixels'] < self.defect_area_min:
continue
# 条件2:高度超过阈值
if defect['height_mm'] < self.defect_height_min:
continue
# 条件3:曲率超过动态阈值
dynamic_threshold = features['mean_curvature'] + self.height_threshold_factor * features['curvature_std']
if defect['curvature'] < dynamic_threshold:
continue
defects.append(defect)
# 2. 多视角融合判定(至少2个不同视角发现同一缺陷才确认)
# (注:实际需结合旋转位置信息,此处简化为数量判断)
if len(defects) >= 1: # 单视角高置信度即可判定
is_defective = True
# 3. 使用Isolation Forest进行二次验证(如果已训练)
if self.is_trained and not is_defective and features['max_curvature'] > 0:
X_test = np.array([[features['mean_curvature'], features['max_curvature'], features['radial_anomaly_score']]])
pred = self.isolation_forest.predict(X_test)
if pred[0] == -1: # 被识别为异常
is_defective = True
# 将最大曲率点作为缺陷记录
if features['defects']:
defects.append(features['defects'][np.argmax([d['curvature'] for d in features['defects']])])
return is_defective, defects
def inspect_multi_view(self, all_features: List[Dict]) -> Tuple[bool, List[Dict]]:
"""
多视角融合检测
:param all_features: 所有旋转角度的特征列表
:return: (是否缺陷, 综合缺陷列表)
"""
combined_defects = []
view_confirmations = {} # 记录各缺陷在不同视角的出现次数
for view_idx, features in enumerate(all_features):
is_def, defects = self.is_defect(features)
if is_def:
for defect in defects:
# 使用缺陷中心坐标作为唯一标识(简化处理)
defect_key = tuple(defect['center'])
if defect_key not in view_confirmations:
view_confirmations[defect_key] = {
'count': 0,
'details': [],
'max_height': 0
}
view_confirmations[defect_key]['count'] += 1
view_confirmations[defect_key]['details'].append({
'view': view_idx,
'height': defect['height_mm']
})
view_confirmations[defect_key]['max_height'] = max(
view_confirmations[defect_key]['max_height'],
defect['height_mm']
)
# 融合判定:至少2个视角确认的缺陷才视为有效
for key, data in view_confirmations.items():
if data['count'] >= 2:
is_defective = True
combined_defects.append({
'center': key,
'view_count': data['count'],
'max_height_mm': data['max_height'],
'views': data['details']
})
return is_defective, combined_defects
5. 主程序 (main.py)
import cv2 import yaml import time import logging import numpy as np from pathlib import Path from threading import Thread, Event from queue import Queue from src.camera import Camera from src.lighting_control import LightingController from src.preprocessor import ImagePreprocessor from src.feature_extractor import FeatureExtractor from src.defect_detector import DefectDetector from src.sorter import SorterController from utils.logger import setup_logger from utils.geometry import find_cap_center
def load_config(config_path: str) -> dict: """加载YAML配置文件""" with open(config_path, 'r', encoding='utf-8') as f: return yaml.safe_load(f)
def capture_thread(camera, lighting, config, task_queue, stop_event): """图像采集线程""" logger = logging.getLogger(name) rotation_steps = config['inspection']['rotation_steps'] step_delay = 0.001 # 旋转步进间隔
while not stop_event.is_set():
all_images = []
for step in range(rotation_steps):
# 触发光源
lighting.trigger()
time.sleep(config['lighting']['trigger_delay'])
# 采集图像
frame = camera.capture_frame()
if frame is not None:
all_images.append(frame)
else:
logger.warning("图像采集失败")
# 控制旋转(通过外部信号或软件延时模拟)
if step < rotation_steps - 1:
time.sleep(step_delay)
if len(all_images) == rotation_steps:
task_queue.put(all_images)
else:
logger.error("图像采集数量不足")
def process_thread(task_queue, preprocessor, feature_extractor, detector, sorter, stop_event, config): """图像处理与判定线程""" logger = logging.getLogger(name) total_count = 0 defect_count = 0
while not stop_event.is_set():
try:
images = task_queue.get(timeout=1.0)
except:
continue
start_time = time.time()
total_count += 1
all_features = []
cap_center = None
for img in images:
# 预处理
preprocessed = preprocessor.preprocess_pipeline(img)
# 首次计算瓶盖中心
if cap_center is None:
cap_center = find_cap_center(preprocessed)
if cap_center is None:
logger.warning("无法定位瓶盖中心")
break
# 特征提取
features = feature_extractor.extract_features(preprocessed, cap_center)
all_features.append(features)
if len(all_features) == config['inspection']['rotation_steps']:
# 多视角融合检测
is_defective, defects = detector.inspect_multi_view(all_features)
if is_defective:
defect_count += 1
logger.info(f"检测到鼓包缺陷! 数量: {len(defects)}, 最大高度: {max(d['max_height_mm'] for d in defects):.3f}mm")
sorter.reject()
else:
sorter.accept()
# 性能监控
process_time = time.time() - start_time
if process_time > config['inspection']['cycle_time_max']:
logger.warning(f"处理超时: {process_time*1000:.2f}ms")
# 定期打印统计
if total_count % 100 == 0:
logger.info(f"统计: 总数={total_count}, 缺陷={defect_count}, 不良率={defect_count/total_count*100:.2f}%")
def main(): # 初始化日志 setup_logger(log_level=logging.INFO) logger = logging.getLogger(name)
# 加载配置
config_path = Path(__file__).parent / 'config' / 'settings.yaml'
config = load_config(str(config_path))
# 初始化各模块
logger.info("初始化系统模块...")
camera = Camera(config['camera'])
lighting = LightingController(config['lighting'])
preprocessor = ImagePreprocessor(config['preprocessing'])
# 假设像素到毫米的转换系数为0.02(需实际标定)
feature_extractor = FeatureExtractor(config['feature_extraction'], pixel_to_mm=0.02)
detector = DefectDetector(config['inspection'])
sorter = SorterController(config['inspection'])
# 训练基线模型(使用无缺陷样本)
# normal_samples = load_normal_samples("data/normal_samples/")
# detector.train_baseline(normal_samples)
# 创建任务队列和停止事件
task_queue = Queue(maxsize=10)
stop_event = Event()
# 启动采集和处理线程
capture_t = Thread(target=capture_thread, args=(camera, lighting, config, task_queue, stop_event))
process_t = Thread(target=process_thread, args=(task_queue, preprocessor, feature_extractor, detector, sorter, stop_event, config))
try:
logger.info("启动瓶盖鼓包检测系统...")
camera.start_capture()
lighting.turn_on()
capture_t.start()
process_t.start()
# 主线程等待
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("用户中断程序")
finally:
stop_event.set()
capture_t.join()
process_t.join()
# 清理资源
camera.stop_capture()
lighting.turn_off()
sorter.close()
# 打印最终统计
logger.info("系统已停止")
if name == "main": main()
五、README文件
瓶盖凸起(鼓包)自动检测系统
项目简介
本系统基于工业机器视觉技术,实现对PET瓶盖生产过程中产生的微小凸起缺陷(鼓包)进行360°全方位自动化检测与分拣。适用于饮料、食品包装行业的瓶盖质量控制环节。
主要特性
- 微米级缺陷识别:可检测高度≥0.08mm、直径≥2mm的凸起
- 多视角融合检测:24视角扫描,消除单一视角遮挡盲区
- 实时处理能力:单瓶盖检测周期≤30ms,兼容1200个/分钟的产线速度
- 自适应阈值判定:基于无缺陷样本的动态阈值调整,适应原料批次波动
- 工业级可靠性:支持长时间连续运行(MTBF>720小时)
系统要求
- Python 3.9+
- OpenCV 4.6+
- NumPy 1.23+
- SciPy 1.10+
- scikit-learn 1.2+
- 工业相机(推荐Basler acA2040-90um,分辨率2048×2048)
- 可编程光源控制器(支持触发信号)
安装步骤
- 克隆仓库
git clone github.com/yourusernam… cd cap-inspection
- 安装依赖
pip install -r requirements.txt
- 配置参数修改 "config/settings.yaml"中的相机参数、光源强度、检测阈值等。
- 标定转换系数通过标准量块标定像素与毫米的转换关系( "pixel_to_mm"),并更新 "feature_extractor.py"中的初始化参数。
- 训练基线模型(可选)收集100+无缺陷瓶盖图像,放入 "data/normal_samples/"目录,取消 "main.py"中训练基线的注释并执行一次。
使用方法
python main.py
目录结构
见上文项目结构说明
故障排除
- 图像模糊:检查相机对焦和曝光时间,确保环形光源无频闪。
- 中心定位失败:调整瓶盖放置平台的高度和同心度,或在 "geometry.py"中优化 "find_cap_center"算法。
- 误检率高:重新采集无缺陷样本训练基线模型,或微调 "settings.yaml"中的 "height_threshold_factor"。
- 处理超时:减少 "rotation_steps"(降低视角数量),或使用GPU加速曲率计算。
贡献指南
欢迎提交Issue和PR,请遵循PEP8编码规范,并提供详细的复现步骤。
许可证
MIT License
联系方式
技术咨询:tech@example.com
六、核心知识点卡片
卡片1:主动光照明技术在缺陷检测中的 利用AI解决实际问题,如果你觉得这个工具好用,欢迎关注长安牧笛!