ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于机器视觉的水果分拣系统:Python实现从识别到气吹的完整闭环

基于机器视觉的水果分拣系统:Python实现从识别到气吹的完整闭环 简介这份资源是一套基于机器视觉的水果分拣系统完整实现面向具备Python基础、希望入门计算机视觉与工业自动化方向的开发者与学习者。系统围绕图像采集、预处理、特征提取、模式识别与执行机构控制展开涵盖灰度化、二值化、滤波去噪、边缘检测等图像处理环节并借助分类算法完成水果种类判别最终驱动传送带、推杆等装置实现自动分拣适合作为课程设计、毕业设计或机器视觉练手项目。资源包共61个文件以23个py源码与29个pyc编译文件为主另含5张jpg示例图、1个ui界面文件及cfg配置、license等辅助文件整体约224KB结构紧凑、模块划分清晰。目前已有184人学习下载。读者可从中获得从图像定位、图像识别到图像分割的完整代码链路以及串口通信、机械臂控制、百度图像接口调用等实践模块便于快速理解机器视觉分拣系统的整体架构与实现思路。1. 从一条传送带说起机器视觉水果分拣系统到底在做什么一条水果传送带摄像头架在正上方果子以每秒两到三个的速度经过后面跟着气吹或推杆。这套「基于机器视觉的水果分拣系统Python」要干的事就是在果子离开视野之前判断它是什么品类、什么等级、有没有瑕疵然后给执行机构一个信号把它吹进对应的料框。听起来简单但真正落地时难点从来不在「识别」本身而在识别结果到机械动作之间的那段链路坐标系怎么对齐、延迟怎么压、光照变了模型还稳不稳。这套系统适合谁适合已经会一点 Python、想找一个完整闭环项目练手的机器视觉应用工程师也适合做农业自动化、果蔬初加工的小团队。它不需要昂贵的深度相机一个普通工业相机加环形光源就能起步算力要求也不高CPU 上跑轻量模型就能到可用帧率。但如果你指望它直接上产线跑几千小时不出错那得先把后面几章的坑踩一遍。这一章先把整体链路讲清楚后面再拆每一步怎么做。2. 系统链路拆解从相机取帧到气吹动作的完整闭环2.1 为什么选 Python 而不是 C 做这套系统很多人一听工业视觉第一反应是 C 加 Halcon。但水果分拣这个场景有个特点算法迭代快、品类经常换、客户今天要分苹果明天要分橙子。用 Python 做原型改一行代码就能重新跑调试周期从半天缩到十分钟。常见做法是 Python 做算法验证和中小产线部署等模型和流程彻底稳定了再把推理部分用 ONNX Runtime 或 TensorRT 加速甚至导出成 C 调用。Python 在这套系统里的角色是「胶水」OpenCV 负责取帧和预处理NumPy 做矩阵运算PyTorch 或 ONNX Runtime 跑分类/检测模型pyserial 或 Modbus 库跟 PLC 通信。整条链路都在一个进程里数据不用来回序列化延迟可控。我一般会把推理单独放一个线程主线程只做取帧和通信避免模型卡顿导致丢帧。选型上还有一个现实原因招人。会 Python 的应届生一抓一大把会 Halcon 加 C 的得加钱。对中小产线来说Python 方案的总体拥有成本明显更低。2.2 硬件最小配置与相机选型参数先给一套能跑起来的最小配置别一上来就堆设备部件最低配置推荐配置说明相机500 万像素 USB 工业相机1200 万像素 GigE 相机分辨率决定最小可检瑕疵尺寸镜头8mm 定焦12mm 低畸变工作距离 300-500mm光源白色环形光可调亮度环形光背光减少反光和阴影算力i5 八代i7 十二代 / 带入门 GPUCPU 可跑轻量模型执行气吹电磁阀多通道气吹阵列响应时间 10ms通信USB 转串口网口 Modbus TCP抗干扰更好相机选型看三个参数分辨率、帧率、接口。水果直径一般 60-90mm要检出 1mm 的瑕疵视野按 200mm 算至少需要 200/1200 像素覆盖考虑余量取 500 万像素2592×1944足够。帧率要匹配传送带速度果子间距 150mm、速度 0.5m/s每秒过 3.3 个相机 15fps 就够留三倍余量选 30fps 更稳。注意USB 相机在长时间高帧率下容易掉帧产线环境优先选 GigE 或 USB3.0并且相机和光源走不同电源回路避免频闪干扰。2.3 用 OpenCV 打通取帧、预处理和坐标映射这一步是整个系统的地基。取帧不难难的是把图像坐标映射到传送带物理坐标否则气吹永远吹偏。核心思路在传送带上贴一个已知尺寸的标定板算出「像素/毫米」比例再根据编码器或时间戳推算果子到达气吹位置时的偏移。import cv2 import numpy as np # 打开相机Windows 下用 DSHOWLinux 下用 V4L2 cap cv2.VideoCapture(0, cv2.CAP_DSHOW) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 2592) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1944) cap.set(cv2.CAP_PROP_FPS, 30) # 标定已知标定板方格 20mm在图像中量得 100 像素 PIXELS_PER_MM 100 / 20.0 # 5 像素/毫米 def preprocess(frame): # 转灰度减少计算量 gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 高斯模糊去噪核大小取奇数 blur cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值应对光照不均 thresh cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 51, 10) return thresh def find_fruits(thresh): # 找轮廓只保留面积在合理范围的 contours, _ cv2.findContours( thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) fruits [] for c in contours: area cv2.contourArea(c) if 2000 area 50000: # 面积阈值按实际像素调整 x, y, w, h cv2.boundingRect(c) cx, cy x w // 2, y h // 2 fruits.append((cx, cy, w, h)) return fruits while True: ret, frame cap.read() if not ret: break thresh preprocess(frame) fruits find_fruits(thresh) for (cx, cy, w, h) in fruits: # 像素坐标转物理坐标假设传送带沿 y 方向运动 phys_x cx / PIXELS_PER_MM phys_y cy / PIXELS_PER_MM cv2.rectangle(frame, (cx-w//2, cy-h//2), (cxw//2, cyh//2), (0, 255, 0), 2) cv2.imshow(frame, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码的逻辑先固定相机参数避免自动曝光导致帧间亮度跳变PIXELS_PER_MM是标定得到的比例所有物理坐标都靠它换算预处理用自适应阈值而不是固定阈值因为传送带上的果子受光照影响大固定阈值在阴影区会漏检。find_fruits里的面积范围是关键参数太小会把噪点当果子太大会把两个挨着的果子合并成一个需要根据实际像素反复调。参数说明高斯核(5,5)适合 500 万像素如果换成 1200 万像素核要相应加大到(7,7)或(9,9)自适应阈值的blockSize51表示每个像素参考周围 51×51 区域果子直径在图像里约 300 像素51 足够覆盖局部光照变化C10是阈值微调量光照越不均匀可以适当加大。2.4 分类模型怎么选轻量 CNN 还是传统特征到了判断品类和瑕疵这一步有两条路。传统特征颜色直方图 HOG SVM在品类少、背景固定时够用训练快、可解释。但水果表面瑕疵形态多变颜色特征容易被光照带偏实际项目里我一般直接上轻量 CNN比如 MobileNetV3 或 ShuffleNet输入 224×224CPU 上单帧推理 20-30ms完全跟得上。数据从哪来初期没有真实产线数据就在传送带上摆不同品类、不同角度的果子每个品类拍 300-500 张瑕疵样本单独拍。数据增强用随机旋转、亮度抖动、高斯噪声模拟产线上的光照波动。标签分两级一级是品类苹果/橙子/柠檬二级是等级优/良/次。如果只做分拣不做分级单标签就够。训练时有个容易忽略的点类别不平衡。优等果永远比瑕疵果多直接训练模型会偏向预测优等。常见做法是在损失函数里加类别权重或者对瑕疵样本过采样。验证集要按「不同光照批次」划分不能随机分否则验证准确率虚高上线就翻车。3. 把模型塞进产线推理加速与通信时序3.1 ONNX 导出与推理线程的写法训练完的 PyTorch 模型直接跑在产线上有两个问题依赖重、启动慢。导出成 ONNX 后用 ONNX Runtime 推理依赖只有几十兆启动秒级。导出时注意固定输入尺寸动态轴虽然灵活但会拖慢推理。import onnxruntime as ort import numpy as np import threading import queue # 导出后加载providers 按优先级排列 session ort.InferenceSession( fruit_cls.onnx, providers[CPUExecutionProvider]) input_name session.get_inputs()[0].name def infer(img_bgr): # 缩放到模型输入尺寸保持 BGR-RGB 顺序 img cv2.resize(img_bgr, (224, 224)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 归一化到 [0,1]再转 NCHW img img.astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1))[np.newaxis, ...] # 推理返回 softmax 后的概率 outputs session.run(None, {input_name: img}) return outputs[0] # 推理线程从队列取图结果放回另一个队列 task_q queue.Queue(maxsize4) result_q queue.Queue(maxsize4) def worker(): while True: item task_q.get() if item is None: break idx, img item prob infer(img) result_q.put((idx, prob.argmax(), prob.max())) t threading.Thread(targetworker, daemonTrue) t.start()逻辑说明providers里如果机器有 GPU把CUDAExecutionProvider放前面CPU 作为兜底。推理线程用队列解耦主线程只管取帧和塞队列队列满就丢最旧的帧保证实时性。maxsize4是经验值太小会频繁丢帧太大会积压导致延迟。结果队列里带上帧序号idx主线程根据序号判断这个结果对应哪个果子避免时序错乱。参数说明输入尺寸 224×224 是 MobileNet 系列的标准输入换成其他模型要对应改归一化用/255.0而不是 ImageNet 均值方差是因为训练时就这么做的推理必须一致否则精度掉得莫名其妙。3.2 触发信号与气吹延迟的补偿这是整套系统最容易翻车的地方。相机拍到果子模型判断完信号发给气吹气吹动作——这中间有几十毫秒延迟。如果传送带速度 0.5m/s50ms 就是 25mm 的偏移果子早过了气吹口。补偿方法有两种一是用编码器实时读传送带位移把延迟换算成距离二是用固定延时前提是传送带速度恒定。我一般用编码器方案稳。编码器每转输出固定脉冲数PLC 读到脉冲就知道传送带走了多远。Python 这边通过 Modbus 读编码器值结合相机触发时刻的编码器值算出果子到达气吹口还需要多少脉冲到点再发吹气指令。from pymodbus.client import ModbusTcpClient import time client ModbusTcpClient(192.168.1.10, port502) client.connect() # 编码器每毫米对应脉冲数标定得到 PULSES_PER_MM 10 # 相机到气吹口的物理距离 CAM_TO_BLOW_MM 300 # 系统总延迟取帧推理通信实测得到 SYSTEM_DELAY_MS 60 def trigger_blow(encoder_at_capture): # 目标脉冲 拍摄时编码器值 距离对应脉冲 延迟补偿 target encoder_at_capture CAM_TO_BLOW_MM * PULSES_PER_MM target int(SYSTEM_DELAY_MS / 1000 * 500 * PULSES_PER_MM) # 假设 0.5m/s while True: current client.read_holding_registers(0, 1).registers[0] if current target: # 写线圈触发气吹地址按 PLC 定义 client.write_coil(0, True) time.sleep(0.01) # 气吹持续 10ms client.write_coil(0, False) break逻辑说明encoder_at_capture是相机触发那一刻读到的编码器值CAM_TO_BLOW_MM是相机光心到气吹口的距离SYSTEM_DELAY_MS是实测的系统延迟。三者相加得到目标脉冲轮询编码器直到到达就触发。time.sleep(0.01)控制气吹时长太短吹不动太长浪费气且可能影响下一个果子。参数说明PULSES_PER_MM必须实测标定不同编码器和轮径组合不一样SYSTEM_DELAY_MS用高速相机拍气吹动作反推或者用示波器测信号到动作的时间轮询间隔受 Modbus 响应时间限制一般 5-10ms如果传送带很快要改用 PLC 内部中断触发Python 只负责下发目标脉冲。3.3 多品类切换时的模型热更新产线经常要换品类总不能每次重启程序。做法是把模型路径和标签映射放在配置文件里检测到配置文件变化就重新加载 ONNX session。注意加载新模型时旧 session 要显式释放否则内存会涨。import os, json, hashlib CONFIG_PATH config.json def load_config(): with open(CONFIG_PATH, r, encodingutf-8) as f: return json.load(f) def file_md5(path): with open(path, rb) as f: return hashlib.md5(f.read()).hexdigest() current_md5 file_md5(CONFIG_PATH) config load_config() session ort.InferenceSession(config[model_path]) while True: new_md5 file_md5(CONFIG_PATH) if new_md5 ! current_md5: config load_config() # 释放旧 session 再加载新的 del session session ort.InferenceSession(config[model_path]) current_md5 new_md5 # ... 主循环逻辑逻辑说明用文件 MD5 判断配置是否变化比监听文件系统事件更可靠跨平台也没坑。del session触发 Python 垃圾回收释放 ONNX Runtime 占用的内存不显式删的话旧模型会一直挂着。标签映射也放在 config 里换品类时改 JSON 就行不用动代码。4. 避坑与排查产线上真正会让你停线的问题4.1 现象白天好好的傍晚开始误判率飙升原因产线窗户透进来的自然光随时间变化相机自动曝光跟着调导致模型输入的亮度分布和训练时不一致。傍晚色温偏暖白平衡也漂了。解决关掉相机自动曝光和自动白平衡全部手动固定。光源用稳压电源供电避免市电波动导致亮度变化。如果实在避不开自然光训练时就把不同时段的光照样本都拍进去让模型见过。4.2 现象两个挨着的果子被识别成一个原因轮廓检测时两个果子的轮廓连在一起find_fruits里面积阈值把合并后的大轮廓当成一个果子。解决先做距离变换再分水岭分割或者用形态学腐蚀把果子分开再找轮廓。更简单的办法是调小面积上限但会漏掉大果子。我一般用分水岭OpenCV 的cv2.watershed配合距离变换效果稳定。4.3 现象气吹偶尔不动作日志里没有报错原因Modbus 写线圈的响应没检查网络抖动时写失败但代码没管。或者气吹电磁阀的电源和相机共用相机触发瞬间拉低电压导致阀不动作。解决每次write_coil后检查返回失败就重试三次。电源分开走电磁阀单独供电。另外气吹管路要定期排水压缩空气里的水汽会让阀卡滞这个跟代码无关但经常被忽略。4.4 现象模型在验证集 99%上线只有 70%原因验证集是随机划分的同一批拍摄的图片既在训练集又在验证集模型记住了背景而不是果子特征。解决按拍摄批次划分数据集训练集和验证集的背景、光照、果子批次完全不重叠。如果数据量够再留一个测试集模拟全新批次。这个坑我踩过不止一次血泪经验就是验证集划分方式比模型结构重要。4.5 现象程序跑几小时后内存持续上涨原因OpenCV 的VideoCapture在某些后端下缓冲区不释放或者推理线程的结果队列没消费导致积压。解决定期cap.release()再重新打开或者换cv2.CAP_GSTREAMER后端。结果队列加maxsize并且主线程必须消费消费不过来就丢。用tracemalloc定位内存增长点别靠猜。5. 进阶技巧用傅里叶变换做表面瑕疵检测前面讲的分类模型擅长判断「是什么」但对细微的表面瑕疵比如苹果的轻微碰伤、橙子的油胞破裂不够敏感因为瑕疵在整图里占比太小CNN 下采样几次就丢了。这时候可以加一路频域检测把果子区域裁出来做傅里叶变换正常果皮纹理在频域有稳定的能量分布瑕疵会引入异常高频或低频分量。import cv2 import numpy as np def freq_defect_score(roi_gray): # 缩放到固定尺寸保证频域可比 roi cv2.resize(roi_gray, (256, 256)) # 傅里叶变换并移到中心 f np.fft.fft2(roi) fshift np.fft.fftshift(f) magnitude np.log1p(np.abs(fshift)) # 取中高频环带能量正常果皮能量集中在中频 h, w magnitude.shape cy, cx h // 2, w // 2 Y, X np.ogrid[:h, :w] dist np.sqrt((X - cx)**2 (Y - cy)**2) # 环带半径按实际纹理周期调这里取 30-80 ring (dist 30) (dist 80) energy magnitude[ring].mean() # 能量偏离正常范围越多瑕疵概率越高 return energy # 正常果皮的能量基线从一批良品统计得到 NORMAL_ENERGY 6.5 THRESHOLD 0.8 score freq_defect_score(roi_gray) if abs(score - NORMAL_ENERGY) THRESHOLD: label defect逻辑说明np.fft.fft2做二维傅里叶变换fftshift把零频移到中心log1p压缩动态范围让能量差异更明显。环带选择是关键半径太小是整体亮度太大是噪声30-80 对应果皮纹理的周期。NORMAL_ENERGY必须从实际良品统计不同品类、不同光照下这个基线不一样换品类要重新统计。参数说明roi缩放到 256×256 是为了让频域坐标和物理纹理周期解耦如果 ROI 尺寸不固定同一个瑕疵在不同尺寸下频域位置会变。环带范围 30-80 是经验值对应纹理周期约 256/80 到 256/30 像素实际要根据相机分辨率和果子大小换算。阈值 0.8 是良品能量波动的三倍标准差太松会漏检太紧会误判。这个频域分数不单独做决策而是和 CNN 的分类概率加权融合分类概率低于 0.9 且频域分数异常时判为次品。融合权重按验证集调我一般给 CNN 0.7、频域 0.3因为 CNN 对品类判断更可靠频域只做瑕疵补充。最后说个习惯每次换品类或换光源我都会先跑一遍良品统计把NORMAL_ENERGY和分类阈值重新标一遍绝不沿用上一批的参数。产线环境没有一劳永逸的参数只有定期校准的习惯。希望帮到你。本文还有配套的精品资源点击获取
返回列表