AI 视觉检测产品 人工智能 量产化检查流程实现
本文介绍了基于YOLO模型的量产测试软件自动化视觉检测系统开发全流程,主要包含以下内容: 需求定义与数据准备 通过摄像头非侵入式检测屏幕测试结果(Pass/Fail) 采集真实设备UI截图作为训练数据 定义7个检测类别(如pass_text、fail_text等) 训练环境搭建 详细说明Windows和Ubuntu系统下的GPU环境配置 提供Miniconda虚拟环境创建和依赖安装命令 介绍YOL
第一部分 项目启动与需求定义
1.1 项目背景与目标
量产测试软件启动后会自动循环测试各个硬件模块(RTC、摄像头、音频、Wi-Fi、按键等),并在屏幕上显示测试状态和结果。任务是训练一个 YOLO 视觉模型,通过非侵入式地拍摄屏幕来自动识别每个模块的测试结论(Pass/Fail),最终代替人工盯屏。
核心设计原则:
-
不与被测设备发生软件交互,仅通过摄像头观察屏幕;
-
模型只负责检测屏幕上的视觉元素(如“PASS”文字、模块名称、打勾图标等),逻辑判断由后处理程序完成;
-
一切从真实硬件出发,所有训练数据必须使用产线实际部署的摄像头采集,严禁使用软件截图替代。
1.2 获取量产测试软件的真实 UI 截图
在定义检测目标前,必须亲眼看到量产测试软件在真实屏幕上的实际显示效果。猜想的 UI 往往会与实际大相径庭。
操作步骤:
-
准备样机:找到一台已烧录量产测试固件的样机(可以是 EVT 板或量产设备),通电启动,让测试程序自动运行。
-
拍摄关键画面:使用手机或 HDMI 采集卡,截取以下状态的屏幕图像:
-
主菜单/待测界面(无任何测试进行)
-
每个模块测试进行中的画面(如:RTC 时钟在跳动、摄像头预览画面出现、音频波形在跳动)
-
每个模块测试通过瞬间的画面
-
每个模块测试失败的画面(可通过人为制造故障获得,例如拔掉摄像头、断开音频环路、不插按键排线等)
-
-
文件命名与保存:将所有图片存入文件夹
ui_reference,使用统一的命名规则:模块名_状态.jpg例如:-
rtc_pass.jpg -
camera_fail.jpg -
audio_running.jpg -
wifi_pass.jpg
-
原理:只有拿到真实 UI,才能准确定义检测类别。例如 RTC 模块可能不是直接显示“PASS”文字,而是一个时钟图标加绿色小勾;音频可能只是一个动态波形条。这些细节都将直接影响后续的标注与训练。
1.3 定义检测目标与类别表
打开一张典型的测试通过画面,对照它列出所有需要 YOLO 检测的视觉元素。将这些元素抽象为固定的类别,并用表格固化。这张表格是整个项目的“法律文件”,后续标注、训练、推理全部以此为唯一依据。
示例类别定义表(请根据实际 UI 调整):
| 类别 ID | 类别名称 | 视觉特征描述 | 出现场景与用途 |
|---|---|---|---|
| 0 | pass_text |
绿色的“PASS”文字(英文或其他语言统一标为此类) | 任何模块测试通过时显示,通用通过标识 |
| 1 | fail_text |
红色的“FAIL”文字 | 任何模块测试失败时显示,通用失败标识 |
| 2 | module_label |
模块名称文字区域,如“RTC”、“Camera”、“Audio”等 | 用于识别当前正在测试哪个模块,需配合 OCR 或位置 |
| 3 | camera_preview |
摄像头预览窗口(实时画面区域) | 有画面则摄像头通路正常 |
| 4 | audio_waveform |
音频波形图或电平指示条 | 检测到波形则音频输入/输出通路正常 |
| 5 | check_icon |
绿色打勾图标 | 可作为辅助通过标识 |
| 6 | cross_icon |
红色叉号图标 | 可作为辅助失败标识 |
类别 ID 从 0 开始必须连续,且一旦确定,在后续所有流程中严禁增删或改变顺序,否则已标注的数据将全部作废。
操作:
-
将上述表格填入项目文档,打印出来贴在标注工位旁;
-
创建文件
classes.txt,内容每行一个类别名称,顺序与 ID 严格对应:
pass_text fail_text module_label camera_preview audio_waveform check_icon cross_icon
该文件将用于标注工具 labelImg 的预定义类别加载。
1.4 确定模型输入源:截图还是相机实拍?
这是影响整个数据采集策略的关键决策,必须在项目启动时敲定。
-
方案 A:使用相机实拍屏幕(推荐) 优点:非侵入,可适配任何设备;缺点:受环境光、反光、相机畸变影响。 若选择此方案,则从第一张训练图开始,就必须使用最终部署在产线上的那台摄像头和固定的光照环境进行拍摄。 绝不能用软件截图训练,否则模型在实拍画面上的准确率可能暴跌 50% 以上。
-
方案 B:通过 ADB/串口等获取设备截图 优点:图像纯净无干扰,精度极高;缺点:需要被测设备开放截图接口,且产线需有物理连接,失去非侵入优势。 若被测设备可稳定回传截图,可直接使用截图进行训练和推理。
决策动作:现在立即确定工控机上使用的摄像头型号(例如:USB 工业相机),并用以下脚本快速拍几张照片,验证清晰度和反光程度是否可接受。
相机验证脚本 verify_camera.py:
import cv2
import os
CAMERA_ID = 0 # 通常 0 代表第一个摄像头
SAVE_DIR = "camera_samples"
os.makedirs(SAVE_DIR, exist_ok=True)
cap = cv2.VideoCapture(CAMERA_ID)
if not cap.isOpened():
raise RuntimeError(f"无法打开摄像头 {CAMERA_ID},检查连接和索引")
# 设置分辨率(根据相机最大能力)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
# 关闭自动曝光(如果相机支持)
cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0) # 0 代表手动模式
cap.set(cv2.CAP_PROP_EXPOSURE, -6) # 曝光值,负值通常降低亮度,需根据实际情况调整
print("相机已打开,按 's' 保存照片,按 'q' 退出。")
count = 0
while True:
ret, frame = cap.read()
if not ret:
print("读取帧失败,检查相机连接。")
break
cv2.imshow("preview", frame)
key = cv2.waitKey(1) & 0xFF
if key == ord('s'):
path = os.path.join(SAVE_DIR, f"sample_{count:03d}.jpg")
cv2.imwrite(path, frame)
print(f"已保存 {path}")
count += 1
elif key == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
在产线光照条件下,将样机屏幕放入拍摄位置,运行此脚本并保存 20 张照片。仔细检查:文字是否清晰?有无过曝或反光光斑?若质量不佳,此刻就需要调整相机角度、光照或曝光参数,而不是等到训练完成后再发现。
1.5 硬件 BOM 与软件版本清单
从项目初始就建立一份完整的配置档案,避免后期环境不一致导致的排查噩梦。
模板:创建文本文件 项目配置清单.txt,逐项填写并归档。
=== 硬件信息 === 工控机型号: CPU: 内存: 硬盘类型与容量: GPU 型号(如 NVIDIA GeForce RTX 3060): 相机品牌型号: 相机接口(USB3.0/GigE): 镜头焦距: 被测设备型号: 屏幕分辨率: 遮光与照明方案简述: === 软件信息 === 操作系统版本: NVIDIA 驱动版本(nvidia-smi 右上角): CUDA 版本(nvidia-smi 右上角): Python 版本(训练和部署分别记录): PyTorch 版本: Ultralytics 版本: OpenCV 版本: ONNX Runtime 版本(若用): 量产测试软件版本及 UI 版本号:
这些信息务必在环境搭建时同步填写。未来若需重训模型或复制工位,检查补充缺失并且记录。
1.6 交付物检查
确认已经完成:
ui_reference文件夹内,各模块的 pass / fail / running 截图齐全;- 填写完毕并确认无误的类别定义表(Excel 或 Markdown);
classes.txt文件已生成;- 已决定采用“相机实拍”还是“截图”作为模型输入源;
- 使用
verify_camera.py完成实拍测试,图像质量满足要求; 项目配置清单.txt已开始填写。
第二部分 训练环境搭建(Windows & Ubuntu 双平台 可执行版)
本部分的目标是在训练计算机(通常是配备 NVIDIA 显卡的台式机或服务器)上,从零开始构建出能够训练 YOLO 模型的完整软件栈。将覆盖 Windows 10/11 和 Ubuntu 22.04/20.04 两种主流操作系统,每一步都提供可直接复制执行的命令,并解释原理与常见问题。
2.1 硬件与环境要求
最低硬件配置
-
GPU:NVIDIA 独立显卡,显存 ≥ 6 GB(如 GTX 1060、RTX 2060、RTX 3060 及以上)。显存大小决定了能使用的 batch size 和输入分辨率。
-
内存:建议 16 GB 及以上,用于数据加载与缓存。
-
硬盘:至少 50 GB 空闲空间(存放数据集、预训练权重、训练缓存和日志)。
-
操作系统:Windows 10/11 或 Ubuntu 22.04/20.04 LTS。
关键原则:采用 PyTorch 自带 CUDA Runtime 的方式,不单独安装 CUDA Toolkit 和 cuDNN,以避免版本冲突。整个环境搭建只需三步:安装显卡驱动 → 创建 Python 虚拟环境 → 安装 PyTorch 和 Ultralytics。
2.2 安装 NVIDIA 显卡驱动
2.2.1 Windows 下安装驱动
-
访问 NVIDIA 官方驱动下载页面,选择显卡型号和操作系统(Windows 10/11 64-bit),下载驱动安装包。
-
右键以管理员身份运行
.exe安装程序,选择“NVIDIA 图形驱动程序”和“GeForce Experience”(后者可选)。在安装选项中勾选 “执行清洁安装”。 -
安装完成后重启电脑。
-
验证:打开命令提示符(cmd)或 PowerShell,输入以下命令并回车:
nvidia-smi
若正常显示 GPU 名称、驱动版本、CUDA 版本(右上角),即表示安装成功。
常见问题:
-
若提示
'nvidia-smi' 不是内部或外部命令→ 驱动安装失败或未重启,重启再试。 -
若反复失败,使用 DDU (Display Driver Uninstaller) 完全卸载旧驱动后再重新安装。
2.2.2 Ubuntu 下安装驱动
打开终端(Ctrl+Alt+T),执行以下命令:
# 1. 更新软件包列表 sudo apt update # 2. 查看系统推荐的驱动版本 ubuntu-drivers devices # 3. 自动安装推荐版本(带 recommend 标记的版本) sudo ubuntu-drivers autoinstall # 或者手动指定版本(如 550): sudo apt install nvidia-driver-550 -y # 4. 重启系统 sudo reboot # 5. 验证 nvidia-smi
常见问题:
-
重启后卡在登录界面、黑屏或分辨率异常 → 通常是因为 Secure Boot 阻止了驱动加载。进入 BIOS 关闭 Secure Boot,或使用
sudo mokutil --disable-validation并重启签署密钥。 -
nvidia-smi报错NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver→ 驱动与内核版本不匹配。可尝试安装带 DKMS 的驱动版本:sudo apt install nvidia-driver-550 dkms -y
-
若使用笔记本电脑(双显卡),可能需要额外配置
prime-select或optimus-manager,但训练建议在台式机上进行。
2.3 安装 Miniconda 并创建虚拟环境
为什么用 Miniconda? 它轻量、跨平台,可隔离不同项目的 Python 环境,避免库版本冲突,且安装 PyTorch 时能自动处理 CUDA 依赖。
2.3.1 Windows 安装 Miniconda
-
下载安装包:Miniconda Windows 64-bit
-
以管理员身份运行安装程序,全程使用默认选项,但务必勾选 “Add Miniconda3 to my PATH environment variable”(将 conda 加入环境变量)。若未勾选,之后需通过开始菜单的“Anaconda Prompt (miniconda3)”启动。
-
安装完成后,打开 Anaconda Prompt 或普通 cmd,输入:
conda --version
显示版本号即成功。
2.3.2 Ubuntu 安装 Miniconda
# 下载安装脚本 wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh # 运行安装(按提示输入 yes 同意协议,安装路径默认即可) bash Miniconda3-latest-Linux-x86_64.sh # 重新加载 shell 配置 source ~/.bashrc # 验证 conda --version
提示:安装最后会询问“是否每次启动自动激活 base 环境”,建议选 yes,方便后续使用。
2.3.3 创建专用虚拟环境
在终端(Windows 用 Anaconda Prompt,Ubuntu 直接用终端)中执行:
# 创建名为 yolo_env 的环境,指定 Python 3.10(兼容性最佳) conda create -n yolo_env python=3.10 -y # 激活环境 conda activate yolo_env
激活后终端提示符前面会出现 (yolo_env),表示当前处于该环境中。后续所有包安装都必须在此环境下进行。
2.4 安装 PyTorch(GPU 版本)
这是最关键的步骤。PyTorch 的 GPU 版自带 CUDA 和 cuDNN 依赖,无需手动安装 CUDA Toolkit。只需要确保显卡驱动版本足够新即可。
2.4.1 确认驱动支持的 CUDA 版本
在终端执行 nvidia-smi,右上角显示“CUDA Version: X.X”,该版本是驱动支持的最高 CUDA 版本。例如 CUDA Version: 12.5 表示可以安装 CUDA 12.5 及以下的 PyTorch 版本。
2.4.2 使用 conda 安装 PyTorch(推荐)
Conda 会自动匹配 cudatoolkit 等依赖,比 pip 更稳定。在 (yolo_env) 环境中,根据驱动选择对应的安装命令:
-
CUDA 12.1 版(推荐,兼容性好,适用于驱动 >= 12.1 的场景):
conda install pytorch torchvision torchaudio pytorch-cuda=12.1 -c pytorch -c nvidia -y
-
CUDA 11.8 版(适用于旧卡如 GTX 1060,或驱动版本低于 12 的环境):
conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia -y
如果下载速度很慢,可以临时使用清华大学镜像源:
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/ conda config --set show_channel_urls yes但不保证镜像更新及时,推荐先尝试官方源。
2.4.3 验证安装
在 (yolo_env) 终端中输入 python 进入交互模式,逐行执行以下代码:
import torch print(torch.__version__) # 应显示 2.x.x print(torch.cuda.is_available()) # 必须返回 True print(torch.cuda.device_count()) # 显示 GPU 数量(如 1) print(torch.cuda.get_device_name(0)) # 显示显卡型号
如果 torch.cuda.is_available() 返回 False,常见原因及解决办法:
| 现象 | 可能原因 | 解决措施 |
|---|---|---|
| 返回 False,且驱动正常 | 安装了 CPU 版本的 PyTorch | 卸载后重新用 conda 命令安装,确保指定 pytorch-cuda |
| 返回 False,nvidia-smi 正常 | CUDA 版本不匹配 | 确认驱动支持的最高 CUDA 版本,安装对应的 PyTorch |
| 返回 False,nvidia-smi 也不正常 | 驱动未正确安装 | 重新安装驱动并重启 |
| 笔记本电脑双显卡 | PyTorch 默认使用集显 | 在设备管理器中禁用集显(Windows 不推荐),或设置环境变量 CUDA_VISIBLE_DEVICES=0 |
卸载重装命令(如需要):
conda uninstall pytorch torchvision torchaudio -y # 然后重新执行正确的 conda install 命令
2.5 安装 Ultralytics YOLO 及辅助工具
在 (yolo_env) 环境下执行:
# 安装 Ultralytics(包含 YOLOv8/v10 等) pip install ultralytics # 安装数据标注工具 labelImg(生成 YOLO 格式标签) pip install labelImg # 安装常用的图像处理与可视化库 pip install opencv-contrib-python matplotlib jupyterlab # 若后续需要导出 ONNX,预装 onnx pip install onnx onnx-simplifier
验证 Ultralytics: 在终端中运行:
from ultralytics import YOLO
model = YOLO('yolov8n.pt') # 自动下载最轻量的预训练模型(约 6 MB)
print("YOLO 模型加载成功")
若没有报错,且能自动下载权重文件,说明安装成功。下载的 yolov8n.pt 会保存在当前目录或 ~/.cache/ultralytics/ 下。
注意:
labelImg如果启动时出现 Qt 插件错误,在 Ubuntu 下执行sudo apt install libxcb-xinerama0;Windows 下一般直接可用。
2.6 仅 CPU 训练的备选方案(无 NVIDIA 显卡)
如果只有 CPU,虽然训练极慢,但可用于小规模数据集的实验或紧急调试。
# 安装 CPU 版 PyTorch conda install pytorch torchvision torchaudio cpuonly -c pytorch -y # 安装 Ultralytics pip install ultralytics
后续训练时,在代码中设置 device='cpu',并将 batch 调小(如 4 或 8),epochs 适当减少。CPU 训练速度可能仅为 GPU 的 1/20 ~ 1/50。
2.7 最终环境检查清单
在 (yolo_env) 环境下,依次执行以下三条验证命令,全部无报错即代表环境就绪:
python -c "import torch; assert torch.cuda.is_available(), 'CUDA not available!'; print('PyTorch CUDA OK')"
python -c "from ultralytics import YOLO; YOLO('yolov8n.pt'); print('Ultralytics OK')"
python -c "import cv2; print('OpenCV version:', cv2.__version__)"
输出应类似于:
PyTorch CUDA OK Ultralytics OK OpenCV version: 4.8.1
2.8 常见问题补充
-
Windows 下无法执行
conda命令 → 重新打开 Anaconda Prompt 或在系统环境变量 Path 中添加 conda 安装目录的Scripts和Library\bin。 -
pip install ultralytics超时 → 使用国内镜像:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple ultralytics。 -
GPU 显存不足导致后期训练崩溃 → 在训练脚本中减小
batch或imgsz,第二部分暂不需要操作。 -
Ubuntu 下
nvidia-smi正常但 PyTorch 仍找不到 GPU → 可能是用户环境变量问题,尝试conda install cudatoolkit=11.8并重装 PyTorch。
第三部分 数据采集与标注
将产线真实场景转化为高质量的模型训练数据。需要进一步补充各种模块失败状态的制造方法、特殊目标的标注技巧、数据均衡与增强的预处理以及标注后验证的自动化。确保数据集覆盖全面、标注一致,为后续训练打下绝对可靠的基础。
3.1 准备工作:类别定义文件
项目根目录下的 classes.txt 示例(务必根据实际 UI 调整):
pass_text fail_text module_label camera_preview audio_waveform check_icon cross_icon
该文件将在标注时加载,且后续 data.yaml 中的 names 字段必须与此完全一致。严禁在标注开始后增删改顺序。
3.2 图像采集的深度补充
3.2.1 相机参数锁定的重要性
产线环境光可能因开门、人员走动而变化。固定曝光和白平衡能确保同一屏幕状态在图像上的像素分布稳定,减少模型误判。使用 3.2.1 的 capture_calibrate.py 找到最佳曝光值后,将该值记录在《项目配置清单》中,后续所有工位都使用相同设定。
3.2.2 各种模块 FAIL 状态的详细制造方法
失败样本的数量和质量是模型能否可靠检出故障的关键。以下提供针对常见模块的具体故障注入手段(请根据设备实际情况选用):
RTC(实时时钟)模块失败:
-
如果设备有可拆卸的 RTC 电池,在测试启动前取下电池,系统时间会复位或报错。
-
通过 ADB/串口发送命令修改系统时间为一个很旧的值,然后启动 RTC 测试,软件可能检测到时间偏差过大而报 FAIL。
-
如果有测试治具可控制,切断 RTC 晶振的供电或短接相关引脚(需硬件支持)。
摄像头模块失败:
-
物理遮挡:用黑胶带或不透光盖子完全遮住摄像头镜头。
-
断开连接:拔掉摄像头 FPC 排线或 USB 连接器。
-
输入异常画面:使用一个坏掉的摄像头模组或使用镜头盖使画面全黑。
音频模块失败:
-
断开回路:如果音频测试是通过播放声音并录音验证,拔掉喇叭或麦克风,或者使用无声音的哑音箱体。
-
注入噪声:在麦克风旁边放置一个强噪声源(如电钻声),使录音信噪比过低。
-
断开电路:在治具上设计开关,可以切断麦克风偏置电压或音频通路。
Wi-Fi/蓝牙模块失败:
-
屏蔽信号:将设备放入金属屏蔽箱,或使用射频屏蔽袋。
-
拔掉天线:如果天线可拆卸,直接取下天线。
-
断开路由器:关闭测试用的 Wi-Fi 接入点,使设备无法连接。
按键/触摸模块失败:
-
移除外接按键板或断开 FPC 连接。
-
在治具上放置一个障碍物,使得某个按键一直被按下,导致其他按键无法被检测。
传感器模块失败:
-
遮住光线传感器、接近传感器等。
-
断开传感器连接线。
通用方法:
-
如果被测设备有调试接口(ADB/串口),可以通过命令直接注入错误结果,这是最可控的方式。例如发送一个命令让测试软件直接跳到 FAIL 界面。
-
如果没有接口,就只能靠物理手段,务必安全,不可损坏设备。
采集时注意事项:
-
每种 FAIL 状态至少采集 200 张,尽量覆盖不同的失败提示界面(如“RTC FAIL”、“Camera Error”等)。
-
如果失败界面显示多种元素(FAIL 文字、红色叉号、错误代码),标注时全部按类别框出,不要遗漏。
3.2.3 运行状态图像的采集
运行状态(RUNNING)图像有助于模型识别“测试尚未结束”,避免将未完成的画面误判为 PASS 或 FAIL。例如:
-
摄像头测试时的实时预览画面(画面内容变化,但边界固定)。
-
音频测试时跳动的波形或电平条。
-
进度条、倒计时、旋转加载图标。
采集这类图像时,只需让测试自动运行,脚本自动抓拍即可。
3.2.4 数据多样性增强(拍摄时)
在采集过程中主动引入变化,能有效提升模型泛化能力:
-
轻微摇动设备或治具(模拟振动)。
-
用手短暂遮挡镜头再移开(模拟操作员手影)。
-
改变环境光(打开/关闭工位照明灯)。
-
如果可能,更换几块不同批次、不同亮度的设备屏幕。
不必担心这些变化影响标注,标注的依据是屏幕上显示的内容,而非像素的绝对亮度。
3.3 数据清洗的补充
除了明显模糊、过曝、全黑等图片,还需剔除:
-
屏幕切换过程中的半帧(画面撕裂)。
-
误操作拍摄的非测试界面(如桌面、菜单)。
-
同一场景完全相同的重复图片过多时,可随机删减,保持一定多样性。
3.4 标注工具与技巧的深化
3.4.1 LabelImg 常见问题处理
-
无法打开图片:确认图片扩展名为小写
.jpg,LabelImg 对大小写敏感。 -
标注时卡顿:图片分辨率太高导致。可在标注前将图片批量缩小到 1280x720 左右(如果文字仍然清晰),但不建议,最好保持原始分辨率,因为产线采集就是高分辨率。
-
多类别选择:如果一张图片包含多个类别目标,必须全部框出,不可只标主要目标。
3.4.2 特殊目标的标注技巧
动态波形框 (audio_waveform) 音频测试区域的波形是实时跳动的,标注时应当框选波形显示区域的固定边界,而不是某一时刻的具体波形图形。因为模型要学习的是这个区域的存在性,而不是波形的瞬时形状。
摄像头预览窗口 (camera_preview) 同样框选预览窗口的固定内边框,包含画面区域。如果预览窗口有黑边,可以包含在内,但尽量精确到显示内容边缘。
模块标签 (module_label) 如果不同模块的标签位置固定,且内容不同,但 UI 中可能同时显示多个模块的状态,仅凭一个通用的 module_label 类别无法知道当前是哪个模块。后续逻辑依赖位置信息或 OCR。更好的做法:若各模块标签视觉差异明显(如“RTC” vs “Camera”),可将它们单独分类(如 label_rtc, label_camera),以避免歧义。但类别数量会增加,需权衡。初期可统一用 module_label,在判决逻辑中结合检测框的坐标进行区分。
小图标 (check_icon, cross_icon) 有些打勾/红叉图标非常小(如 20x20 像素),在 640x640 训练尺寸下可能只有几个像素。训练时可通过增大输入分辨率(imgsz=960)或对训练集图像进行上采样来改善。标注时必须精确到图标边界,不可过大。
3.4.3 标注一致性检查
标注完成后,使用 visualize_bboxes.py 抽查时,要特别关注同类目标的框大小是否一致。如果有的框很紧,有的框很松,会导致模型边界回归不准。可在团队内进行交叉检查。
3.5 数据集划分的进一步调整
原始 85%/15% 的划分对于大多数情况足够。但如果总图片量较少(<500 张),可调整为 90%/10% 以保留更多训练数据。如果 FAIL 样本远少于 PASS,可考虑分层采样:确保训练集中 FAIL 和 PASS 的比例与真实产线接近(通常 1:1 以上更好),可以在划分时增加 FAIL 样本被分到训练集的概率。
改进版划分脚本(含分层采样) 可如下实现(将修改后的 split_dataset.py 命名为 split_dataset_balanced.py):
import os
import random
import shutil
from pathlib import Path
from collections import defaultdict
random.seed(42)
RAW_DIR = "raw_dataset"
OUTPUT_DIR = "datasets/factory_test"
TRAIN_RATIO = 0.85
# 收集所有图片并记录来源文件夹(例如 rtc_pass)
folder_images = defaultdict(list)
for img_path in Path(RAW_DIR).rglob("*.jpg"):
if img_path.with_suffix(".txt").exists():
folder_name = img_path.parent.name # 如 "rtc_pass"
folder_images[folder_name].append(img_path)
# 为每个文件夹分别划分
for folder, img_list in folder_images.items():
random.shuffle(img_list)
n_train = max(1, int(len(img_list) * TRAIN_RATIO))
for i, img_path in enumerate(img_list):
txt_path = img_path.with_suffix(".txt")
if i < n_train:
dest_img = Path(OUTPUT_DIR) / "train" / "images"
dest_lbl = Path(OUTPUT_DIR) / "train" / "labels"
else:
dest_img = Path(OUTPUT_DIR) / "val" / "images"
dest_lbl = Path(OUTPUT_DIR) / "val" / "labels"
dest_img.mkdir(parents=True, exist_ok=True)
dest_lbl.mkdir(parents=True, exist_ok=True)
new_stem = f"{folder}_{img_path.stem}"
shutil.copy2(img_path, dest_img / (new_stem + ".jpg"))
shutil.copy2(txt_path, dest_lbl / (new_stem + ".txt"))
print("分层划分完成。")
这样可以保证每个子文件夹(如 rtc_fail)的训练和验证比例大致相同,避免某些稀少的失败样本全部落到验证集。
3.6 数据集描述文件 data.yaml 的最终确认
确保 path 使用绝对路径,nc 数值正确,类别名称顺序与 classes.txt 一致。同时检查是否有不可见字符或多余空格。
示例(Linux):
path: /home/factory/yolo_factory_test/datasets/factory_test train: train/images val: val/images nc: 7 names: 0: pass_text 1: fail_text 2: module_label 3: camera_preview 4: audio_waveform 5: check_icon 6: cross_icon
3.7 数据集最终验证与统计
运行 validate_labels.py 确保零错误后,可运行以下统计脚本查看数据分布:
脚本 dataset_statistics.py:
from pathlib import Path
from collections import Counter
DATASET_DIR = Path("datasets/factory_test")
for split in ["train", "val"]:
lbl_dir = DATASET_DIR / split / "labels"
cls_counter = Counter()
for lbl_file in lbl_dir.glob("*.txt"):
with open(lbl_file) as f:
for line in f:
cls_id = int(line.split()[0])
cls_counter[cls_id] += 1
print(f"--- {split} 集类别统计 ---")
for cls_id, count in sorted(cls_counter.items()):
print(f" {cls_id} : {count} 个实例")
print(f" 图片总数: {len(list((DATASET_DIR/split/'images').glob('*.jpg')))}")
如果发现某些类别(如 fail_text)实例数远少于其他,应立即回去补充采集。理想情况下,训练集中每类的实例数应尽量均衡,至少不低于 200 个实例(框的数量)。
3.8 风险与应对总结表
| 风险点 | 影响 | 预防与纠正 |
|---|---|---|
| FAIL 样本不足 | 模型很难学到失败特征,漏检严重 | 按 3.2.2 方法多制造故障,分层采样 |
| 标注框不一致 | 边界回归不准,mAP 低 | 标注前制定规则,交叉检查,使用抽查脚本 |
| 动态内容误标 | 模型学习到瞬态特征,推理时失效 | 标注固定边界,不标具体波形/画面 |
| 混淆文字未处理 | 假阳性高 | 收集含混淆文字的图片,不框,加入训练 |
| 不同工位光照差异大 | 模型在某个工位失效 | 采集时变光照,训练时增强亮度/对比度 |
| 类别顺序错误 | 训练时类别混淆 | 保持 classes.txt、data.yaml、标注三者顺序一致,运行 validate_labels.py |
第四部分:模型训练
通过完整的训练脚本启动 YOLOv8 模型训练。将得到可直接运行的 train.py。
4.1 训练前的最终检查
在启动训练之前,请确保以下文件与结构完全正确。
4.1.1 目录结构确认
项目根目录下应至少包含:
yolo_factory_test/ ├── datasets/ │ └── factory_test/ │ ├── train/ │ │ ├── images/ │ │ └── labels/ │ ├── val/ │ │ ├── images/ │ │ └── labels/ │ └── data.yaml ├── classes.txt └── train.py (即将创建)
4.1.2 数据集描述文件 data.yaml
打开 datasets/factory_test/data.yaml,确认路径和类别名称无误。
示例 (Linux):
path: /home/yourname/yolo_factory_test/datasets/factory_test train: train/images val: val/images nc: 7 names: 0: pass_text 1: fail_text 2: module_label 3: camera_preview 4: audio_waveform 5: check_icon 6: cross_icon
示例 (Windows):
path: C:/Users/yourname/yolo_factory_test/datasets/factory_test train: train/images val: val/images nc: 7 names: 0: pass_text 1: fail_text 2: module_label 3: camera_preview 4: audio_waveform 5: check_icon 6: cross_icon
务必使用绝对路径,否则训练时可能因为相对路径解析错误而找不到图片。如果不确定当前工作目录,可以在 train.py 中动态构造绝对路径(后面会给出方案)。
4.1.3 标注文件验证
在终端运行之前编写的 validate_labels.py,确保输出 所有标注文件格式检查通过。。
4.2 选择预训练模型
Ultralytics 提供了多种尺寸的预训练模型。对于屏幕 UI 检测,文字和图标通常较小且边界清晰,推荐从 YOLOv8s 开始,它在速度和精度之间取得了良好平衡。
-
yolov8n.pt– Nano,速度最快,适合算力极低的设备。 -
yolov8s.pt– Small,推荐起步选项。 -
yolov8m.pt– Medium,精度更高,需更多显存。 -
yolov8l.pt– Large,更高的精度,更慢。
初次训练时,请使用 yolov8s.pt,如果效果不理想再尝试 yolov8m.pt。
第一次运行训练脚本时,Ultralytics 会自动从网络下载该权重文件(约 22 MB),也可以预先下载放置在项目根目录。
4.3 完整训练脚本 train.py
将以下代码保存为项目根目录下的 train.py。它包含了详细的中文注释,并针对屏幕检测任务优化了数据增强策略。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
量产测试屏幕检测模型训练脚本
使用 Ultralytics YOLOv8,训练自定义数据集。
使用方法:
1. 激活环境:conda activate yolo_env
2. 确保 datasets/factory_test/data.yaml 已配置正确
3. 在项目根目录执行:python train.py
"""
from ultralytics import YOLO
import os
import platform
def main():
# ==================== 1. 加载预训练模型 ====================
# 根据需求选择:yolov8n.pt / yolov8s.pt / yolov8m.pt / yolov8l.pt
model = YOLO('yolov8s.pt')
# ==================== 2. 核心训练参数 ====================
# 数据集配置文件路径,建议使用绝对路径以免出错
data_yaml = os.path.abspath('datasets/factory_test/data.yaml')
if not os.path.exists(data_yaml):
raise FileNotFoundError(f"找不到数据集配置文件: {data_yaml}")
# 训练轮数:屏幕 UI 相对简单,通常 100~150 轮足以收敛
epochs = 150
# 早停耐心值:如果验证集损失在 patience 轮内无改善,则提前终止
patience = 50
# 输入图片尺寸:标准 640,如果小文字多可增大到 960
imgsz = 640
# 批大小:根据显存调整
# 8 GB 显存可用 16;6 GB 用 8;显存更大可设为 32
batch = 16
# 设备:'0' 表示第一块 GPU,'cpu' 表示使用 CPU
device = 0
# 数据加载子进程数:Windows 下多进程有坑,必须设为 0;Linux 设为 CPU 核心数的一半
workers = 0 if platform.system() == 'Windows' else 4
# ==================== 3. 优化器与学习率 ====================
# 优化器保持自动选择(通常为 AdamW)
optimizer = 'auto'
# 初始学习率,微调时保持默认 0.01 即可
lr0 = 0.01
# 最终学习率因子,余弦退火衰减到底 lr = lr0 * lrf
lrf = 0.01
# 权重衰减,轻微正则化
weight_decay = 0.0005
# 学习率预热轮数
warmup_epochs = 3
# 使用余弦学习率调度
cos_lr = True
# ==================== 4. 数据增强设置 ====================
# 因为相机是固定机位拍摄屏幕,几何变换不应太剧烈
# 保留颜色/亮度扰动来应对光照变化
augment = True
# 关闭旋转、剪切、透视等几何增强
degrees = 0 # 不旋转
translate = 0.1 # 轻微随机平移
scale = 0.5 # 缩放幅度
shear = 0.0 # 不剪切
perspective = 0.0 # 无透视变换
flipud = 0.0 # 不上下翻转
fliplr = 0.5 # 左右翻转概率(一般屏幕 UI 左右对称)
# HSV 增强,模拟不同光照与屏幕色温
hsv_h = 0.015
hsv_s = 0.7
hsv_v = 0.4
# 最后 10 个 epoch 关闭 Mosaic 增强,提升收敛稳定性
close_mosaic = 10
# ==================== 5. 训练输出与保存 ====================
# 输出根目录
project = 'runs/detect'
# 本次训练的标识名称
name = 'factory_test_s'
# 如果目录已存在则覆盖(调试阶段方便,正式训练可改为 False)
exist_ok = True
# 每 20 个 epoch 保存一次中间权重,用于回溯
save_period = 20
# 使用预训练权重
pretrained = True
# 显示详细信息
verbose = True
# 随机种子,保证可复现
seed = 42
# ==================== 6. 启动训练 ====================
results = model.train(
data=data_yaml,
epochs=epochs,
patience=patience,
imgsz=imgsz,
batch=batch,
device=device,
workers=workers,
optimizer=optimizer,
lr0=lr0,
lrf=lrf,
weight_decay=weight_decay,
warmup_epochs=warmup_epochs,
cos_lr=cos_lr,
augment=augment,
degrees=degrees,
translate=translate,
scale=scale,
shear=shear,
perspective=perspective,
flipud=flipud,
fliplr=fliplr,
hsv_h=hsv_h,
hsv_s=hsv_s,
hsv_v=hsv_v,
close_mosaic=close_mosaic,
save=True,
save_period=save_period,
project=project,
name=name,
exist_ok=exist_ok,
pretrained=pretrained,
verbose=verbose,
seed=seed
)
# ==================== 7. 训练完成信息 ====================
best_weight = os.path.join(results.save_dir, 'weights', 'best.pt')
print(f"\n 训练完成!最佳模型保存在: {best_weight}")
# 打印关键指标
try:
map50 = results.results_dict.get('metrics/mAP50(B)', 'N/A')
print(f"验证集 mAP50: {map50}")
except:
pass
if __name__ == '__main__':
main()
提示:如果希望训练过程中自动将图片缓存到内存以提高速度(需要足够大的内存),可以在
model.train()中添加cache=True参数。但对于大量图片可能导致内存溢出,请谨慎。
4.3.1 替代方案:命令行启动训练
如果不想使用 Python 脚本,也可以用 Ultralytics 提供的命令行工具:
yolo detect train \ data=datasets/factory_test/data.yaml \ model=yolov8s.pt \ epochs=150 \ imgsz=640 \ batch=16 \ device=0 \ workers=4 \ patience=50 \ project=runs/detect \ name=factory_test_s \ exist_ok=True
但命令行无法精细控制增强参数,推荐使用上面的脚本。
4.4 训练过程监控
训练启动后,终端会输出每个 epoch 的进度条和指标。
Epoch GPU_mem box_loss cls_loss dfl_loss Instances Size 1/150 2.5G 1.23 2.34 1.56 12 640: 100%|█| Class Images Instances Box(P R mAP50 mAP50-95): 100%|█| all 314 1200 0.68 0.52 0.56 0.37
需要关注:
-
box_loss, cls_loss, dfl_loss:应持续下降,在后期趋于平缓。
-
mAP50:验证集上的平均精度,应在训练中逐步上升,最终理想值 > 0.9。
-
mAP50-95:更严格,一般低于 mAP50。
-
若验证损失开始上升而训练损失仍在下降,说明出现了过拟合,可考虑提前停止或增加数据。
Ultralytics 会自动在 runs/detect/factory_test_s/ 目录下生成 results.png 图表,可以随时打开查看损失和 mAP 曲线。
4.4.1 使用 TensorBoard(可选)
如果需要更丰富的可视化,可在训练前安装 TensorBoard:
pip install tensorboard
然后在另一终端中运行:
tensorboard --logdir runs/detect
打开浏览器访问 http://localhost:6006 即可查看实时训练曲线。
4.5 训练过程中的常见问题与处理
| 现象 | 可能原因 | 解决措施 |
|---|---|---|
CUDA out of memory |
显存不足 | 减小 batch 或 imgsz;关闭其他占用显存的程序 |
| 训练速度极慢,GPU 利用率低 | 数据加载成为瓶颈,或 workers 设置不当 |
检查硬盘速度;Windows 下确保 workers=0;可尝试 cache=True 将图片缓存到 RAM |
| Loss 变成 NaN | 学习率太高,或标注坐标有异常值 | 降低 lr0 到 0.001,重新运行 validate_labels.py |
| mAP 一直很低(<0.5)且不涨 | 标注存在大量错误,或 data.yaml 路径/类别错乱 |
再次运行标注检查,抽查几张图片的预测结果与真实标签对比 |
| 某个类别 AP 极低 | 该类样本太少,或标注框大小不合适 | 检查该类实例数量,采集更多该类图片,确认标注是否统一 |
| 训练中途停止但未报错 | 可能触发了早停(patience) | 查看日志,如果是因为 patience 停止且 mAP 已收敛,则属于正常;若未收敛则增大 patience 或 epochs |
| Windows 下训练卡死或 DataLoader 错误 | 多进程加载问题 | 设置 workers=0 并确保代码在 if __name__ == '__main__': 中 |
| 下载预训练权重超时 | 网络问题 | 手动下载权重文件放到项目目录,然后用 YOLO('yolov8s.pt') 仍会优先使用本地文件 |
4.6 训练后的操作
训练完成或早停后,runs/detect/factory_test_s/weights/ 下会生成:
-
best.pt:验证集上 mAP 最高的模型 -
last.pt:最后一个 epoch 的模型
立即验证最佳模型(可选,但强烈建议):
在终端执行:
from ultralytics import YOLO
model = YOLO('runs/detect/factory_test_s/weights/best.pt')
metrics = model.val(data='datasets/factory_test/data.yaml', imgsz=640, conf=0.001, iou=0.6)
print(metrics.box.map50) # mAP50
print(metrics.box.map) # mAP50-95
在测试集图片上可视化推理结果:
model.predict(source='datasets/factory_test/val/images', save=True, conf=0.25, iou=0.5)
然后打开 runs/detect/predict/ 下的图片,目视检查框的准确性。
4.7 何时需要重新训练或调整
-
如果 mAP50 < 0.85:必须改善数据集(增加样本、修正标注)或调整训练参数(增大 imgsz、换大模型)。
-
如果某类别的 Recall 很低:优先补充该类别的样本,尤其是 FAIL 类。
-
如果过拟合严重:增加数据增强强度,或采集更多真实变化的图片。
模型训练不是一次性工作,而是反复迭代的过程。
第五部分:模型验证、测试与阈值调优(完整可执行版)
训练完成后,不能直接将 best.pt 丢到产线上。必须用模型从未见过的数据进行严格评估,找出假阳性、假阴性,并调整置信度阈值与非极大抑制参数,使模型在实际生产中达到极低的误判率。本部分将提供全部可执行脚本,并解释每一步的原理和潜在风险。
5.1 为什么要做独立测试集评估?
训练时使用的验证集(val)参与了早停和模型选择,模型对它已经产生了一定程度的“间接过拟合”。因此,必须准备一组完全未参与训练的图片来模拟真实的未知产线环境。如果之前用第三部分的脚本只划分了 train 和 val,建议重新从 raw_dataset 划分出 train / val / test 三部分,比例可设为 80% / 10% / 10%。
重新划分脚本(如果已经有训练好的模型不想重新训练,可暂时复用 val 作为测试集;但长期看必须独立划分)——将其保存为 split_dataset_v2.py,执行后将生成 test 集。
import os
import random
import shutil
from pathlib import Path
random.seed(42)
RAW_DIR = "raw_dataset"
OUTPUT_DIR = "datasets/factory_test"
TRAIN_RATIO = 0.8
VAL_RATIO = 0.1
# 剩下的 0.1 作为 test
for split in ["train", "val", "test"]:
(Path(OUTPUT_DIR) / split / "images").mkdir(parents=True, exist_ok=True)
(Path(OUTPUT_DIR) / split / "labels").mkdir(parents=True, exist_ok=True)
all_images = list(Path(RAW_DIR).rglob("*.jpg"))
print(f"共找到 {len(all_images)} 张图片")
for img_path in all_images:
txt_path = img_path.with_suffix(".txt")
if not txt_path.exists():
print(f"警告:{img_path} 无标注,跳过")
continue
r = random.random()
if r < TRAIN_RATIO:
split = "train"
elif r < TRAIN_RATIO + VAL_RATIO:
split = "val"
else:
split = "test"
dest_img = Path(OUTPUT_DIR) / split / "images"
dest_lbl = Path(OUTPUT_DIR) / split / "labels"
new_stem = f"{img_path.parent.name}_{img_path.stem}"
shutil.copy2(img_path, dest_img / (new_stem + ".jpg"))
shutil.copy2(txt_path, dest_lbl / (new_stem + ".txt"))
print("划分完成。")
for split in ["train", "val", "test"]:
n = len(list((Path(OUTPUT_DIR)/split/"images").glob("*.jpg")))
print(f"{split}: {n} 张")
然后需要重新训练模型,因为训练集变成了原来的 80%。如果不想重训,可暂时将 val 当作测试集使用,但评估结果会偏乐观。
5.2 用测试集计算详细指标
创建 data_test.yaml,拷贝 data.yaml 内容,将 val 字段改为测试集路径:
path: /home/yourname/yolo_factory_test/datasets/factory_test # 绝对路径 train: train/images val: test/images # 指向 test 集 nc: 7 names: 0: pass_text 1: fail_text 2: module_label 3: camera_preview 4: audio_waveform 5: check_icon 6: cross_icon
评估脚本 evaluate_model.py:
from ultralytics import YOLO
import os
MODEL_PATH = 'runs/detect/factory_test_s/weights/best.pt'
DATA_YAML = 'datasets/factory_test/data_test.yaml'
model = YOLO(MODEL_PATH)
# val 模式,设置 conf=0.001 保留所有候选框以便后续分析
metrics = model.val(
data=DATA_YAML,
imgsz=640,
batch=16,
conf=0.001,
iou=0.6,
device=0,
split='test', # 明确使用 test 集
plots=True, # 生成混淆矩阵、P-R曲线等
save_json=True # 保存每张图的详细预测结果
)
print(f"mAP50: {metrics.box.map50:.3f}")
print(f"mAP50-95: {metrics.box.map:.3f}")
# 每个类的 AP
for i, ap in enumerate(metrics.box.maps):
print(f"Class {i}: mAP50-95 = {ap:.3f}")
运行后,输出目录中会生成 confusion_matrix.png、PR_curve.png 等图表,可以直观查看各类别的精度和召回率。对于量产测试,fail_text 的 Recall 必须接近 1.0,否则不良品可能漏过。
5.3 深度错误分析——找出每一张误检/漏检图片
Ultralytics 的 val 模式虽然提供了指标,但不会直接展示出“哪张图片出错了”。需要一个脚本,遍历测试集,将预测框与标注框进行匹配,并自动保存错检图片,供人工分析。
脚本 error_analysis.py:
import cv2
import numpy as np
from pathlib import Path
from ultralytics import YOLO
import os
MODEL_PATH = 'runs/detect/factory_test_s/weights/best.pt'
TEST_IMG_DIR = Path('datasets/factory_test/test/images')
TEST_LBL_DIR = Path('datasets/factory_test/test/labels')
CLASS_NAMES = {0: 'pass_text', 1: 'fail_text', 2: 'module_label', 3: 'camera_preview',
4: 'audio_waveform', 5: 'check_icon', 6: 'cross_icon'}
CONF_THRESH = 0.25
IOU_THRESH = 0.5
SAVE_DIR = Path('error_analysis_results')
SAVE_DIR.mkdir(exist_ok=True)
model = YOLO(MODEL_PATH)
def calculate_iou(box1, box2):
# box1, box2 都是归一化的 (x,y,w,h)
b1_x1 = box1[0] - box1[2]/2
b1_y1 = box1[1] - box1[3]/2
b1_x2 = box1[0] + box1[2]/2
b1_y2 = box1[1] + box1[3]/2
b2_x1 = box2[0] - box2[2]/2
b2_y1 = box2[1] - box2[3]/2
b2_x2 = box2[0] + box2[2]/2
b2_y2 = box2[1] + box2[3]/2
inter_x1 = max(b1_x1, b2_x1)
inter_y1 = max(b1_y1, b2_y1)
inter_x2 = min(b1_x2, b2_x2)
inter_y2 = min(b1_y2, b2_y2)
inter_area = max(0, inter_x2 - inter_x1) * max(0, inter_y2 - inter_y1)
b1_area = (b1_x2 - b1_x1) * (b1_y2 - b1_y1)
b2_area = (b2_x2 - b2_x1) * (b2_y2 - b2_y1)
iou = inter_area / (b1_area + b2_area - inter_area + 1e-8)
return iou
for img_path in TEST_IMG_DIR.glob('*.jpg'):
lbl_path = TEST_LBL_DIR / (img_path.stem + '.txt')
if not lbl_path.exists():
continue
# 读取标注
gt_boxes = []
with open(lbl_path) as f:
for line in f:
parts = line.strip().split()
if len(parts) != 5:
continue
cls_id, cx, cy, w, h = map(float, parts)
gt_boxes.append([int(cls_id), cx, cy, w, h])
# 模型推理
results = model(img_path, conf=CONF_THRESH, iou=IOU_THRESH, verbose=False)
pred_boxes = []
if results[0].boxes is not None:
for box in results[0].boxes:
cls_id = int(box.cls[0])
xyxyn = box.xyxyn[0].cpu().numpy() # [x1,y1,x2,y2] 归一化
w = xyxyn[2] - xyxyn[0]
h = xyxyn[3] - xyxyn[1]
cx = xyxyn[0] + w/2
cy = xyxyn[1] + h/2
pred_boxes.append([cls_id, cx, cy, w, h])
# 匹配
matched_gt = set()
matched_pred = set()
for gi, g in enumerate(gt_boxes):
best_iou = 0
best_pi = -1
for pi, p in enumerate(pred_boxes):
if pi in matched_pred or g[0] != p[0]:
continue
iou = calculate_iou(g[1:], p[1:])
if iou > best_iou:
best_iou = iou
best_pi = pi
if best_iou > 0.5 and best_pi != -1:
matched_gt.add(gi)
matched_pred.add(best_pi)
fn = len(gt_boxes) - len(matched_gt)
fp = len(pred_boxes) - len(matched_pred)
if fn > 0 or fp > 0:
img = cv2.imread(str(img_path))
h, w = img.shape[:2]
# 画真实框(绿色)
for g in gt_boxes:
x1 = int((g[1] - g[3]/2) * w)
y1 = int((g[2] - g[4]/2) * h)
x2 = int((g[1] + g[3]/2) * w)
y2 = int((g[2] + g[4]/2) * h)
cv2.rectangle(img, (x1,y1), (x2,y2), (0,255,0), 2)
cv2.putText(img, CLASS_NAMES[g[0]], (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1)
# 画预测框(红色)
for p in pred_boxes:
x1 = int((p[1] - p[3]/2) * w)
y1 = int((p[2] - p[4]/2) * h)
x2 = int((p[1] + p[3]/2) * w)
y2 = int((p[2] + p[4]/2) * h)
cv2.rectangle(img, (x1,y1), (x2,y2), (0,0,255), 2)
cv2.putText(img, CLASS_NAMES[p[0]], (x1, y2+15), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,255), 1)
cv2.imwrite(str(SAVE_DIR / f"{img_path.stem}_error.jpg"), img)
print(f"错检图片: {img_path.name} - 漏检{fn}, 误检{fp}")
print(f"错误图片已保存至 {SAVE_DIR}")
分析输出:逐一打开保存的错误图片,总结出错规律。例如:是否因为反光、文字模糊、标签标注错误等。对于量产测试而言,fail_text 的漏检(FN)是最危险的,必须归零。
5.4 置信度阈值与 NMS 参数调优
默认的置信度阈值(conf=0.25)不一定适合所有类别。特别是 fail_text 通常需要更低的阈值来保证高召回率。将通过阈值扫描脚本找到使 F1 最大的全局阈值,并可针对不同类别单独设置。
脚本 tune_threshold.py:
from ultralytics import YOLO
import numpy as np
from pathlib import Path
MODEL_PATH = 'runs/detect/factory_test_s/weights/best.pt'
IMG_DIR = Path('datasets/factory_test/test/images')
LBL_DIR = Path('datasets/factory_test/test/labels')
IOU_THRESH = 0.5
model = YOLO(MODEL_PATH)
# 读取所有图片和标注
img_paths = list(IMG_DIR.glob('*.jpg'))
all_gt = []
for p in img_paths:
lbl_p = LBL_DIR / (p.stem + '.txt')
boxes = []
if lbl_p.exists():
with open(lbl_p) as f:
for line in f:
parts = line.strip().split()
if len(parts) == 5:
cls_id, cx, cy, w, h = map(float, parts)
boxes.append([int(cls_id), cx, cy, w, h])
all_gt.append(boxes)
def match_at_conf(conf):
tp = fp = fn = 0
for i, gt in enumerate(all_gt):
results = model(img_paths[i], conf=conf, iou=IOU_THRESH, verbose=False)
preds = []
if results[0].boxes is not None:
for box in results[0].boxes:
xyxyn = box.xyxyn[0].cpu().numpy()
cls_id = int(box.cls[0])
w = xyxyn[2] - xyxyn[0]
h = xyxyn[3] - xyxyn[1]
cx = xyxyn[0] + w/2
cy = xyxyn[1] + h/2
preds.append([cls_id, cx, cy, w, h])
matched_gt = set()
matched_pred = set()
for gi, g in enumerate(gt):
best_iou = 0
best_pi = -1
for pi, p in enumerate(preds):
if pi in matched_pred or p[0] != g[0]:
continue
# IoU 计算(简化)
gx1 = g[1]-g[3]/2; gy1 = g[2]-g[4]/2; gx2 = g[1]+g[3]/2; gy2 = g[2]+g[4]/2
px1 = p[1]-p[3]/2; py1 = p[2]-p[4]/2; px2 = p[1]+p[3]/2; py2 = p[2]+p[4]/2
ix = max(0, min(gx2,px2)-max(gx1,px1))
iy = max(0, min(gy2,py2)-max(gy1,py1))
iou = ix*iy / ((gx2-gx1)*(gy2-gy1) + (px2-px1)*(py2-py1) - ix*iy + 1e-8)
if iou > best_iou:
best_iou = iou
best_pi = pi
if best_iou > 0.5 and best_pi != -1:
matched_gt.add(gi)
matched_pred.add(best_pi)
tp += len(matched_gt)
fn += len(gt) - len(matched_gt)
fp += len(preds) - len(matched_pred)
precision = tp / (tp + fp + 1e-8)
recall = tp / (tp + fn + 1e-8)
f1 = 2 * precision * recall / (precision + recall + 1e-8)
return precision, recall, f1
# 扫描 0.1 到 0.9 步长 0.05
best_f1 = 0
best_conf = 0
for conf in np.arange(0.1, 0.95, 0.05):
p, r, f1 = match_at_conf(conf)
print(f"conf={conf:.2f} P={p:.3f} R={r:.3f} F1={f1:.3f}")
if f1 > best_f1:
best_f1 = f1
best_conf = conf
print(f"\n推荐全局置信度阈值: {best_conf:.2f} (F1={best_f1:.3f})")
针对不同类别设定独立阈值: fail_text 的漏检代价极高,可在推理后处理时将它的置信度阈值降低,例如设为 0.15,而其他类别使用全局最佳阈值。第六部分的部署代码中将实现此逻辑。
5.5 NMS IoU 阈值的影响
如果屏幕上多个模块的 PASS 框排列很密集,过高的 NMS IoU 阈值可能导致应该保留的框被错误抑制。可在推理时测试不同的 iou 参数(如 0.5 和 0.7),查看是否有漏框现象。通常保持 0.5~0.6 为宜。
5.6 根据错误分析结果改进模型
根据 error_analysis.py 的输出,常见改进措施:
| 错误类型 | 改进操作 |
|---|---|
| 反光导致误检 PASS | 采集反光照片加入训练集,增强亮度扰动 |
| 小目标 check_icon 漏检 | 增大输入分辨率 imgsz 到 960,重训练 |
| 模糊文字误判 | 训练时加入高斯模糊、运动模糊增强 |
| 标注错误(框歪、类别错) | 修正标注,重新训练 |
| FAIL 类漏检 | 补充 FAIL 样本,尤其注意模糊、反光情况下的 FAIL |
每一次模型迭代后,重复本部分的评估流程,直至在测试集上满足以下产线标准:
-
fail_text的 Recall ≥ 0.99 -
整体 Precision ≥ 0.98
-
无规律性的假阳性或假阴性出现
5.7 产线模拟——多帧确认验证
在量产环境中,会用连续多帧的检测结果来投票决定最终状态,这可以过滤掉瞬时的闪烁或误检。可以提前用测试集图片序列模拟这一逻辑,验证多帧确认是否能消除偶发错误。脚本在第七部分系统集成中会详细给出。
第六部分 模型导出与独立推理部署
本部分将把训练得到的 best.pt 转换为不依赖 PyTorch、不依赖 Ultralytics 的轻量化推理引擎,使得工控机仅需安装 onnxruntime 和 opencv-python 即可高速运行。将获得可直接使用的 ONNX 推理类,并掌握转换为 TensorRT 加速的方法。所有步骤都配有完整脚本和原理解释。
6.1 为什么要导出模型?
训练好的 best.pt 包含了完整的 PyTorch 模型结构,其运行需要整个 PyTorch 环境(>2 GB),启动慢、内存占用大,不适合产线长期稳定运行。导出为 ONNX 或 TensorRT 后:
-
脱离训练框架,仅需轻量推理库;
-
推理速度提升,可通过 FP16/INT8 进一步加速;
-
跨平台部署更灵活(C++/C# 均可调用 ONNX)。
对于屏幕检测这类固定场景,推荐路径:best.pt → ONNX → 在工控机上用 ONNX Runtime GPU 推理,或进一步转为 TensorRT Engine。
6.2 导出 ONNX 模型
在训练环境(已安装 ultralytics 和 torch)中执行以下脚本。将固定输入尺寸为 640×640,并启用图简化。
脚本:export_onnx.py
from ultralytics import YOLO
model = YOLO('runs/detect/factory_test_s/weights/best.pt')
# 导出 ONNX
model.export(
format='onnx',
imgsz=640,
dynamic=False, # 固定 batch=1,便于后续部署
simplify=True, # 调用 onnx-simplifier 简化计算图
opset=12, # 算子集版本,兼容性最好
half=False # 先导出 FP32,后续可再转换为 FP16
)
print("ONNX 导出完成,文件为 best.onnx")
执行后会在同级目录生成 best.onnx。
参数说明:
-
dynamic=False:强制固定 batch size 和分辨率,推理时无需处理动态维度,易于优化。 -
simplify=True:自动融合冗余算子、去除无用节点,减小模型体积并加速推理。 -
opset=12:大多数推理引擎(ONNX Runtime、TensorRT、OpenVINO)对该版本支持最好。 -
half=False:现阶段保留 FP32 精度,FP16 稍后通过转换工具实现,以保持灵活性。
常见问题:
-
若报错
ModuleNotFoundError: No module named 'onnx',请先执行pip install onnx onnx-simplifier。 -
若模型输出形状与预期不符(例如类别数不对),请确认第四部分训练时
data.yaml中的nc与实际类别数一致。
6.3 使用 ONNX Runtime 进行纯推理(完全独立于 Ultralytics)
工控机部署只需安装:
pip install onnxruntime-gpu opencv-python numpy
(若只有 CPU,安装 onnxruntime 即可)
以下提供完整的推理类 YOLOv8ONNX,它封装了预处理、后处理(解码 + NMS),可直接用于检测屏幕元素。保存为 yolo_onnx_inference.py。
脚本:yolo_onnx_inference.py
import cv2
import numpy as np
import onnxruntime as ort
class YOLOv8ONNX:
def __init__(self, onnx_path, conf_thres=0.5, iou_thres=0.5):
self.conf_thres = conf_thres
self.iou_thres = iou_thres
# 优先使用 GPU,若无则回退 CPU
providers = ['CUDAExecutionProvider', 'CPUExecutionProvider']
self.session = ort.InferenceSession(onnx_path, providers=providers)
# 获取模型输入信息
self.input_name = self.session.get_inputs()[0].name
input_shape = self.session.get_inputs()[0].shape # [1, 3, 640, 640]
self.input_h = input_shape[2]
self.input_w = input_shape[3]
# 从输出维度自动推断类别数(输出通道数 = 4 + nc)
output_shape = self.session.get_outputs()[0].shape # [1, 4+nc, 8400]
self.nc = output_shape[1] - 4
print(f"ONNX 模型已加载,输入尺寸: {self.input_w}x{self.input_h}, 类别数: {self.nc}")
def preprocess(self, img_bgr):
"""将 BGR 图像缩放至模型输入尺寸并归一化"""
img = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (self.input_w, self.input_h))
img = img.transpose(2, 0, 1).astype(np.float32) / 255.0 # CHW, [0,1]
img = np.expand_dims(img, axis=0)
return img
def postprocess(self, outputs, original_shape):
"""
解码 YOLOv8 输出并执行 NMS。
输入: outputs[0] 形状 [1, 4+nc, num_anchors],数值已归一化
返回: boxes (xyxy 像素坐标), class_ids, confidences
"""
preds = outputs[0][0] # shape: [4+nc, 8400]
preds = np.transpose(preds, (1, 0)) # [8400, 4+nc]
boxes = preds[:, :4] # cx, cy, w, h (归一化)
scores = preds[:, 4:] # 类别分数
class_ids = np.argmax(scores, axis=1)
confidences = np.max(scores, axis=1)
# 初步置信度过滤
mask = confidences > self.conf_thres
boxes = boxes[mask]
confidences = confidences[mask]
class_ids = class_ids[mask]
if len(boxes) == 0:
return np.array([]), np.array([]), np.array([])
# 转换 cxcywh → xyxy 并缩放到原图尺寸
orig_h, orig_w = original_shape
xyxy = np.zeros_like(boxes)
xyxy[:, 0] = (boxes[:, 0] - boxes[:, 2] / 2) * orig_w
xyxy[:, 1] = (boxes[:, 1] - boxes[:, 3] / 2) * orig_h
xyxy[:, 2] = (boxes[:, 0] + boxes[:, 2] / 2) * orig_w
xyxy[:, 3] = (boxes[:, 1] + boxes[:, 3] / 2) * orig_h
# NMS
indices = cv2.dnn.NMSBoxes(xyxy.tolist(), confidences.tolist(),
self.conf_thres, self.iou_thres)
if len(indices) > 0:
indices = indices.flatten()
return xyxy[indices], class_ids[indices], confidences[indices]
return np.array([]), np.array([]), np.array([])
def detect(self, img_bgr):
"""完整推理流程,输入 BGR 图像,返回检测结果"""
orig_h, orig_w = img_bgr.shape[:2]
input_tensor = self.preprocess(img_bgr)
outputs = self.session.run(None, {self.input_name: input_tensor})
return self.postprocess(outputs, (orig_h, orig_w))
# ---------- 测试示例 ----------
if __name__ == '__main__':
# 实例化检测器,置信度阈值可根据实际情况调整
detector = YOLOv8ONNX('best.onnx', conf_thres=0.5, iou_thres=0.5)
img = cv2.imread('test_screen.jpg')
if img is None:
print("测试图片不存在,请提供一张产线拍摄的屏幕图片")
else:
boxes, cls_ids, confs = detector.detect(img)
class_names = ['pass_text','fail_text','module_label','camera_preview',
'audio_waveform','check_icon','cross_icon']
for box, cls_id, conf in zip(boxes, cls_ids, confs):
x1, y1, x2, y2 = map(int, box)
cv2.rectangle(img, (x1, y1), (x2, y2), (0,255,0), 2)
label = f"{class_names[cls_id]}: {conf:.2f}"
cv2.putText(img, label, (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1)
cv2.imwrite('onnx_result.jpg', img)
print("推理结果已保存至 onnx_result.jpg")
原理说明:
-
YOLOv8 的 ONNX 输出格式为
[1, 4+nc, 8400],其中 8400 是特征图上的 anchor 数量(针对 640×640 输入)。每个 anchor 的前 4 个值是中心点坐标和宽高(已归一化),后面的nc个值是类别分数。 -
后处理先提取每个 anchor 的类别最大值作为其分类,然后还原到原图坐标,最后用 OpenCV 的
NMSBoxes进行非极大抑制,滤除重叠框。
性能测试:
在目标工控机上运行以下代码(或在开发机上评估)以了解推理速度:
import time
import cv2
from yolo_onnx_inference import YOLOv8ONNX
detector = YOLOv8ONNX('best.onnx')
img = cv2.imread('test_screen.jpg')
# 预热
for _ in range(20):
detector.detect(img)
# 计时
times = []
for _ in range(100):
t0 = time.time()
detector.detect(img)
times.append(time.time() - t0)
print(f"平均推理时间: {sum(times)/len(times)*1000:.2f} ms")
print(f"FPS: {1/(sum(times)/len(times)):.1f}")
在典型工控机(RTX 3060)上,FP32 ONNX 推理延迟约 15~30 ms,可轻松满足实时要求。
6.4 进一步提升性能:生成 FP16 ONNX
在训练环境导出时,设置 half=True 可直接得到 FP16 模型:
model.export(format='onnx', imgsz=640, half=True, simplify=True, opset=12)
FP16 模型体积减半,在支持 Tensor Core 的 GPU 上推理延迟可再降 30% 左右。精度损失对于屏幕文字检测通常可忽略,但需在测试集上验证。
风险:如果 GPU 计算能力低于 6.1(如 GTX 750 等旧卡),FP16 可能不被支持。部署前请在工控机上用 benchmark.py 验证。
6.5 使用 TensorRT 加速(可选,NVIDIA GPU 推荐)
TensorRT 是 NVIDIA 的专用推理优化器,能对模型进行层融合、内核自动调优,并支持 INT8 量化,通常比 ONNX Runtime 更快。
6.5.1 通过命令行工具 trtexec 转换
在工控机上安装 TensorRT(参考 NVIDIA 官方文档),然后执行:
trtexec --onnx=best.onnx --fp16 --saveEngine=best_fp16.engine
生成 best_fp16.engine 后,可使用 pycuda 编写推理代码。此处提供核心推理类 YOLOv8TensorRT,保存为 yolo_trt_inference.py。
脚本:yolo_trt_inference.py
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit
import numpy as np
import cv2
class YOLOv8TensorRT:
def __init__(self, engine_path, conf_thres=0.5, iou_thres=0.5):
self.conf_thres = conf_thres
self.iou_thres = iou_thres
self.logger = trt.Logger(trt.Logger.WARNING)
with open(engine_path, 'rb') as f, trt.Runtime(self.logger) as runtime:
self.engine = runtime.deserialize_cuda_engine(f.read())
self.context = self.engine.create_execution_context()
# 分配缓冲区
self.inputs = []
self.outputs = []
self.bindings = []
for binding in self.engine:
shape = self.engine.get_binding_shape(binding)
size = trt.volume(shape)
dtype = trt.nptype(self.engine.get_binding_dtype(binding))
host_mem = cuda.pagelocked_empty(size, dtype)
device_mem = cuda.mem_alloc(host_mem.nbytes)
self.bindings.append(int(device_mem))
if self.engine.binding_is_input(binding):
self.inputs.append({'host': host_mem, 'device': device_mem, 'shape': shape})
else:
self.outputs.append({'host': host_mem, 'device': device_mem, 'shape': shape})
self.input_h = self.inputs[0]['shape'][2]
self.input_w = self.inputs[0]['shape'][3]
# 假设输出与 ONNX 相同,可根据实际 engine 调整
self.nc = self.outputs[0]['shape'][1] - 4 # 4 + nc
def preprocess(self, img_bgr):
img = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (self.input_w, self.input_h))
img = img.transpose(2, 0, 1).astype(np.float32) / 255.0
return np.ascontiguousarray(img)
def postprocess(self, output_host, orig_shape):
# 输出形状 [1, 4+nc, 8400]
preds = output_host.reshape(1, self.nc+4, -1)[0] # [4+nc, 8400]
preds = np.transpose(preds, (1, 0)) # [8400, 4+nc]
boxes = preds[:, :4]
scores = preds[:, 4:]
class_ids = np.argmax(scores, axis=1)
confidences = np.max(scores, axis=1)
mask = confidences > self.conf_thres
boxes = boxes[mask]
confidences = confidences[mask]
class_ids = class_ids[mask]
if len(boxes) == 0:
return np.array([]), np.array([]), np.array([])
orig_h, orig_w = orig_shape
xyxy = np.zeros_like(boxes)
xyxy[:, 0] = (boxes[:, 0] - boxes[:, 2]/2) * orig_w
xyxy[:, 1] = (boxes[:, 1] - boxes[:, 3]/2) * orig_h
xyxy[:, 2] = (boxes[:, 0] + boxes[:, 2]/2) * orig_w
xyxy[:, 3] = (boxes[:, 1] + boxes[:, 3]/2) * orig_h
indices = cv2.dnn.NMSBoxes(xyxy.tolist(), confidences.tolist(),
self.conf_thres, self.iou_thres)
if len(indices) > 0:
indices = indices.flatten()
return xyxy[indices], class_ids[indices], confidences[indices]
return np.array([]), np.array([]), np.array([])
def detect(self, img_bgr):
orig_h, orig_w = img_bgr.shape[:2]
input_img = self.preprocess(img_bgr)
np.copyto(self.inputs[0]['host'], input_img.ravel())
cuda.memcpy_htod(self.inputs[0]['device'], self.inputs[0]['host'])
self.context.execute_v2(self.bindings)
for out in self.outputs:
cuda.memcpy_dtoh(out['host'], out['device'])
output = self.outputs[0]['host']
return self.postprocess(output, (orig_h, orig_w))
注意事项:
-
TensorRT Engine 与构建时的 GPU 架构严格绑定,换卡需重新生成。
-
如果使用 INT8 量化,需要提供校准数据集,精度可能略有下降,务必验证。
如果工控机不方便安装 TensorRT 和 pycuda,直接使用 ONNX Runtime GPU 版即可满足绝大多数需求,TensorRT 仅在极致低延迟要求时使用。
6.6 部署环境最小化配置
工控机无需安装 Python 训练环境,仅需:
# 创建精简环境 conda create -n deploy python=3.10 -y conda activate deploy pip install onnxruntime-gpu opencv-python numpy pyyaml # 若用 TensorRT 另加相应包
将 best.onnx、yolo_onnx_inference.py 以及后续的业务逻辑脚本拷贝到工控机即可运行。整个环境体积 < 500 MB(含 CUDA 运行时)。
Windows 补充:如果 onnxruntime-gpu 无法加载 CUDA,请安装 Visual C++ Redistributable,并确保显卡驱动正常。
6.7 模型精度对比测试
部署前,务必用同一批测试图像对比 best.pt(Ultralytics 推理)和 ONNX 模型的结果,确保导出后无精度损失。可编写简易脚本,计算两者检测框的 IoU 差距。通常情况下,simplify=True 且 opset=12 导出的 ONNX 与原始模型结果完全一致或仅有浮点舍入误差。
快速对比:
from ultralytics import YOLO
from yolo_onnx_inference import YOLOv8ONNX
import cv2
pt_model = YOLO('best.pt')
onnx_model = YOLOv8ONNX('best.onnx', conf_thres=0.5, iou_thres=0.5)
img = cv2.imread('test.jpg')
# PyTorch 推理
res_pt = pt_model(img, conf=0.5, iou=0.5, verbose=False)[0].boxes
# ONNX 推理
boxes_on, cls_on, conf_on = onnx_model.detect(img)
# 简单对比框的数量和大致位置
print("PT 检出框数:", len(res_pt) if res_pt else 0)
print("ONNX 检出框数:", len(boxes_on))
正常情况下两者检出应高度一致,若偏差过大,检查预处理和后处理逻辑是否与 Ultralytics 对齐(RGB/BGR、归一化范围等)。
第七部分 系统集成与自动化测试主控程序
7.1 项目文件结构
在工控机上创建以下目录结构(可拷贝整个 production_test 文件夹):
production_test/ ├── config.yaml # 所有可调参数 ├── main.py # 主程序入口(状态机测试流程) ├── camera.py # 相机采集模块(线程安全,取最新帧) ├── detector.py # ONNX 推理封装(支持按类别设定不同阈值) ├── logger.py # 日志与数据库记录 ├── alarm.py # 串口报警灯控制(可选) ├── yolo_onnx_inference.py # 第六部分提供的 YOLOv8ONNX 核心推理类 └── best.onnx # 训练并导出的模型文件
需要将前六部分生成的 best.onnx 和 yolo_onnx_inference.py 放入此目录。
7.2 配置文件 config.yaml
将所有可调参数外置,现场部署时仅需修改此文件,无需改动代码。
# ========== 相机参数 ========== camera: id: 0 # 摄像头索引 width: 1920 height: 1080 exposure: -6 # 手动曝光值(根据 3.2.1 节调试确定) auto_exposure: 0 # 0=手动, 1=自动 auto_wb: 0 # 白平衡锁定,0=手动 # ========== 模型参数 ========== model: onnx_path: "best.onnx" conf_threshold: 0.5 # 默认置信度阈值 iou_threshold: 0.5 # NMS 的 IoU 阈值 # ========== 测试流程 ========== test_sequence: modules: ["RTC", "CAMERA", "AUDIO", "WIFI", "BUTTON"] # 按此顺序测试 timeout_per_module: 30 # 每个模块的超时时间(秒) confirm_frames: 3 # 连续多少帧通过才判定为 PASS frame_interval: 0.2 # 每次推理间隔(秒),应大于推理耗时 # ========== 针对特定类别的独立阈值 ========== thresholds: fail_text: 0.15 # 失败类降低阈值,宁可错杀不可放过 # ========== 数据库 ========== database: path: "test_results.db" # ========== 报警 ========== alarm: enable: true # 是否启用报警灯 port: "COM3" # 串口号,若无硬件填 null baudrate: 9600
7.3 相机采集模块 camera.py
后台线程持续抓取最新帧,确保推理不因采集而阻塞。
import cv2
import threading
import time
from queue import Queue, Empty
class CameraCapture:
def __init__(self, camera_id=0, width=1920, height=1080, exposure=-6, auto_exposure=0, auto_wb=0):
self.cap = cv2.VideoCapture(camera_id)
if not self.cap.isOpened():
raise RuntimeError(f"无法打开摄像头 {camera_id}")
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
self.cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, auto_exposure)
self.cap.set(cv2.CAP_PROP_EXPOSURE, exposure)
self.cap.set(cv2.CAP_PROP_AUTO_WB, auto_wb)
self.queue = Queue(maxsize=2) # 仅保留最新帧
self.running = True
self.thread = threading.Thread(target=self._grab, daemon=True)
self.thread.start()
time.sleep(1) # 等待相机稳定
def _grab(self):
while self.running:
ret, frame = self.cap.read()
if not ret:
print("[相机] 读取失败,尝试重新初始化...")
self.cap.release()
time.sleep(2)
# 重新打开相机(实际使用中建议抛出异常让外层处理)
continue
# 清空旧帧,仅保留最新一帧
while not self.queue.empty():
try:
self.queue.get_nowait()
except Empty:
break
self.queue.put(frame)
def get_frame(self):
"""非阻塞获取最新帧,若无则返回 None"""
try:
return self.queue.get_nowait()
except Empty:
return None
def release(self):
self.running = False
if self.thread.is_alive():
self.thread.join(timeout=2)
self.cap.release()
原理:使用最大长度为 2 的队列,连续清空旧帧,保证推理拿到的永远是最新画面,避免因推理耗时导致滞后。
7.4 推理模块封装 detector.py
基于第六部分的 YOLOv8ONNX,增加按类别设置不同置信度阈值的功能。
import numpy as np
from yolo_onnx_inference import YOLOv8ONNX
class Detector(YOLOv8ONNX):
def __init__(self, onnx_path, conf_thres=0.5, iou_thres=0.5, class_thresholds=None):
super().__init__(onnx_path, conf_thres, iou_thres)
self.class_thresholds = class_thresholds if class_thresholds else {}
def detect_filtered(self, img_bgr):
"""先按默认阈值粗检,再用各类别独立阈值过滤"""
boxes, cls_ids, confs = self.detect(img_bgr)
if len(boxes) == 0:
return boxes, cls_ids, confs
keep = []
for i, cls_id in enumerate(cls_ids):
th = self.class_thresholds.get(int(cls_id), self.conf_thres)
if confs[i] >= th:
keep.append(i)
if keep:
return boxes[keep], cls_ids[keep], confs[keep]
return np.array([]), np.array([]), np.array([])
7.5 日志与数据库记录 logger.py
SQLite 数据库存储测试结果,同时将失败或低置信度画面保存为图片,便于追溯。
import sqlite3
import datetime
import cv2
import os
class ResultLogger:
def __init__(self, db_path="test_results.db", image_dir="failed_images"):
self.conn = sqlite3.connect(db_path)
self.cursor = self.conn.cursor()
self.cursor.execute('''CREATE TABLE IF NOT EXISTS results
(id INTEGER PRIMARY KEY AUTOINCREMENT,
serial_number TEXT,
module TEXT,
status TEXT,
confidence REAL,
timestamp TEXT,
image_path TEXT)''')
self.conn.commit()
self.image_dir = image_dir
os.makedirs(image_dir, exist_ok=True)
def log(self, sn, module, status, confidence=0.0, image=None):
timestamp = datetime.datetime.now().isoformat()
image_path = ""
if image is not None and status in ("FAIL", "TIMEOUT", "LOW_CONF"):
filename = f"{sn}_{module}_{timestamp.replace(':','-')}.jpg"
image_path = os.path.join(self.image_dir, filename)
cv2.imwrite(image_path, image)
self.cursor.execute("INSERT INTO results (serial_number, module, status, confidence, timestamp, image_path) VALUES (?,?,?,?,?,?)",
(sn, module, status, confidence, timestamp, image_path))
self.conn.commit()
def close(self):
self.conn.close()
7.6 报警模块 alarm.py
通过串口控制报警灯(可选),无硬件时仅打印日志。
import serial
import time
class Alarm:
def __init__(self, port=None, baudrate=9600):
self.ser = None
if port:
try:
self.ser = serial.Serial(port, baudrate, timeout=1)
time.sleep(2)
except Exception as e:
print(f"[报警] 无法连接串口 {port}: {e}")
def trigger(self, status):
if self.ser:
# 假设 'P' 亮绿灯,'F' 亮红灯并蜂鸣
if status == "FAIL":
self.ser.write(b'F')
elif status == "PASS":
self.ser.write(b'P')
else:
print(f"[报警] {status}")
def close(self):
if self.ser:
self.ser.close()
7.7 主程序 main.py
这是整个自动化测试的核心。它按 config.yaml 中定义的模块顺序逐项测试,利用多帧确认逻辑避免偶然误判,并记录所有结果。
import time
import yaml
import sys
import cv2
import numpy as np
from camera import CameraCapture
from detector import Detector
from logger import ResultLogger
from alarm import Alarm
CLASS_NAMES = {
0: 'pass_text', 1: 'fail_text', 2: 'module_label',
3: 'camera_preview', 4: 'audio_waveform', 5: 'check_icon', 6: 'cross_icon'
}
def load_config(config_path='config.yaml'):
with open(config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def check_module(detections, target_module):
"""
通用模块检测逻辑:
- 出现 fail_text 立即判失败
- 同时存在 pass_text 和 module_label 则判通过(简化,未严格匹配模块名)
- 仅看到 module_label 无 pass/fail 则继续等待
"""
boxes, cls_ids, confs = detections
has_pass = 0 in cls_ids
has_fail = 1 in cls_ids
has_label = 2 in cls_ids
if has_fail:
return 'FAIL'
if has_pass and has_label:
return 'PASS'
if has_label and not has_pass and not has_fail:
return 'WAIT'
return 'UNKNOWN'
def main():
config = load_config()
# 初始化各模块
cam = CameraCapture(
camera_id=config['camera']['id'],
width=config['camera']['width'],
height=config['camera']['height'],
exposure=config['camera']['exposure'],
auto_exposure=config['camera']['auto_exposure'],
auto_wb=config['camera']['auto_wb']
)
detector = Detector(
onnx_path=config['model']['onnx_path'],
conf_thres=config['model']['conf_threshold'],
iou_thres=config['model']['iou_threshold'],
class_thresholds=config.get('thresholds', {})
)
logger = ResultLogger(db_path=config['database']['path'])
alarm = Alarm(
port=config['alarm'].get('port') if config['alarm'].get('enable') else None,
baudrate=config['alarm'].get('baudrate', 9600)
)
modules = config['test_sequence']['modules']
timeout = config['test_sequence']['timeout_per_module']
confirm_frames = config['test_sequence']['confirm_frames']
frame_interval = config['test_sequence']['frame_interval']
sn = input("请输入设备序列号: ").strip()
print(f"开始测试序列: {modules}")
for module in modules:
print(f"\n--- 测试模块: {module} ---")
start_time = time.time()
pass_count = 0
final_status = "TIMEOUT"
last_frame = None
while time.time() - start_time < timeout:
frame = cam.get_frame()
if frame is None:
time.sleep(0.05)
continue
last_frame = frame.copy()
boxes, cls_ids, confs = detector.detect_filtered(frame)
status = check_module((boxes, cls_ids, confs), module)
if status == 'FAIL':
final_status = 'FAIL'
break
elif status == 'PASS':
pass_count += 1
if pass_count >= confirm_frames:
final_status = 'PASS'
break
else:
pass_count = 0
# 保存低置信度通过帧(用于后续模型优化)
if status == 'PASS' and min(confs[cls_ids == 0], default=1.0) < 0.7:
logger.log(sn, module, "LOW_CONF", confidence=float(min(confs[cls_ids == 0])), image=frame)
time.sleep(frame_interval)
# 记录最终结果并报警
logger.log(sn, module, final_status, confidence=1.0 if final_status == 'PASS' else 0.0, image=last_frame)
if final_status == 'PASS':
alarm.trigger("PASS")
print(f"模块 {module} -> PASS")
else:
alarm.trigger("FAIL")
print(f"模块 {module} -> {final_status}")
# 模块间等待界面切换
time.sleep(1)
print("\n所有模块测试完毕。")
cam.release()
logger.close()
alarm.close()
if __name__ == '__main__':
try:
main()
except Exception as e:
print(f"程序异常退出: {e}")
sys.exit(1)
关键逻辑解释:
-
多帧确认:只有当连续
confirm_frames帧同时出现pass_text和module_label时才判定通过,避免单帧闪烁或误检。 -
FAIL 优先:一旦出现
fail_text,立即终止当前模块测试并记为失败,保证高灵敏度。 -
超时处理:若模块在规定时间内未完成,记录为 TIMEOUT 并继续下一个模块。
-
低置信度捕获:若模型以较低置信度判为 PASS,自动保存该帧以便后期审查和模型迭代。
7.8 针对特定模块的扩展检查函数(可选)
上述 check_module 是通用逻辑,假设界面有 PASS/FAIL 文字和模块标签。如果摄像头、音频等模块使用专属视觉元素(如预览窗口、波形),可扩展字典映射:
def check_camera(detections, _):
boxes, cls_ids, confs = detections
if 1 in cls_ids: # fail_text
return 'FAIL'
if 3 in cls_ids and 1 not in cls_ids: # camera_preview 存在且无失败
return 'PASS'
return 'WAIT'
# 在主循环中根据模块名选择检查函数
check_functions = {
"CAMERA": check_camera,
# "AUDIO": check_audio, ...
}
check_func = check_functions.get(module, check_module)
status = check_func((boxes, cls_ids, confs), module)
在实际项目中,应根据 UI 的具体情况定制这些函数。
7.9 部署与运行
-
将
production_test整个文件夹拷贝至工控机。 -
确保已按第六部分部署要求安装 Python 环境及依赖:
conda create -n deploy python=3.10 -y conda activate deploy pip install onnxruntime-gpu opencv-python numpy pyyaml pyserial
-
根据工位实际光照调整
config.yaml中的曝光值、置信度阈值、模块列表。 -
连接摄像头、报警灯(可选)、扫码枪(序列号输入),运行:
python main.py
-
观察终端输出,验证每个模块的检测是否符合预期。
注意事项:
-
序列号输入示例中使用
input,产线上可替换为扫码枪(模拟键盘输入)或自动从被测设备读取。 -
如果摄像头意外断连,
camera.py中的重连逻辑较简单,生产环境建议结合异常检测并自动重启程序。 -
数据库
test_results.db会不断增长,需配合第八部分的清理策略定期维护。
第八部分 压力测试、性能优化与长期稳定性
经过严苛的压力测试和针对性优化,才能保证它在产线 7×24 小时不间断运行中始终保持准确、稳定、高效。本部分将提供完整的性能评估脚本、优化手段和稳定性验证方法,所有操作均可直接执行,无任何伪代码。
8.1 建立性能基线
在对系统做任何改动前,必须精确测量当前状态下的推理延迟、吞吐量、GPU/CPU/内存占用,以便优化后对比。以下脚本使用真实测试集图片(不小于 100 张),模拟连续推理场景,输出关键指标。
脚本 benchmark_baseline.py(放在 production_test/ 目录下运行)
import time
import cv2
import os
import numpy as np
from detector import Detector
import psutil
import GPUtil
MODEL_PATH = 'best.onnx'
TEST_IMAGE_DIR = '../datasets/factory_test/test/images' # 使用第五部分准备好的测试集
CONF_THRES = 0.5
IOU_THRES = 0.5
def benchmark():
# 加载模型
detector = Detector(MODEL_PATH, conf_thres=CONF_THRES, iou_thres=IOU_THRES)
# 读取所有测试图片
img_files = [os.path.join(TEST_IMAGE_DIR, f) for f in os.listdir(TEST_IMAGE_DIR) if f.endswith('.jpg')]
if len(img_files) == 0:
print("测试集图片不存在,请检查路径")
return
images = []
for f in img_files[:200]: # 最多用200张
img = cv2.imread(f)
if img is not None:
images.append(img)
# 预热
dummy = np.random.randint(0, 255, (1080, 1920, 3), dtype=np.uint8)
for _ in range(20):
detector.detect_filtered(dummy)
# 性能测量
latencies = []
gpu_mems = []
cpu_list = []
ram_list = []
for idx, img in enumerate(images):
t0 = time.time()
boxes, cls_ids, confs = detector.detect_filtered(img)
elapsed = (time.time() - t0) * 1000 # 毫秒
latencies.append(elapsed)
# 每10帧采样资源占用,避免测量本身影响时序
if idx % 10 == 0:
if GPUtil.getGPUs():
gpu_mems.append(GPUtil.getGPUs()[0].memoryUsed)
cpu_list.append(psutil.cpu_percent(interval=None))
ram_list.append(psutil.virtual_memory().percent)
avg_lat = sum(latencies) / len(latencies)
fps = 1000.0 / avg_lat
print(f"平均推理延迟: {avg_lat:.2f} ms")
print(f"吞吐量: {fps:.1f} FPS")
print(f"GPU 显存占用峰值: {max(gpu_mems) if gpu_mems else 'N/A'} MB")
print(f"CPU 占用峰值: {max(cpu_list) if cpu_list else 'N/A'}%")
print(f"内存占用峰值: {max(ram_list) if ram_list else 'N/A'}%")
if __name__ == '__main__':
benchmark()
原理与解读:
-
预热是为了避免首次推理包含 CUDA 初始化、模型加载等一次性开销,影响真实延迟评估。
-
资源占用采样间隔设为 10 帧,防止
psutil调用本身成为性能瓶颈。 -
产线环境若需实时处理,平均延迟应 < 100 ms,理想 < 50 ms。若当前延迟过高,按下一节方法优化。
8.2 推理加速优化
8.2.1 FP16 半精度推理
在训练环境导出 FP16 的 ONNX 模型(参考第六部分):
model.export(format='onnx', imgsz=640, half=True, simplify=True, opset=12)
得到 best_fp16.onnx。将其拷贝到工控机,直接替换原模型路径进行基准测试。FP16 通常可将延迟降低 30%~50%,精度几乎无损。
风险:部分老旧 GPU(计算能力 < 6.1)不支持 FP16 加速,此时 onnxruntime-gpu 会退回到 FP32 计算,延迟不会降低,但也不会报错。可在基准测试前后对比确认。
8.2.2 TensorRT 引擎(可选,最强加速)
如果工控机配有 NVIDIA 显卡,且安装了 TensorRT,可进一步加速。使用命令行工具 trtexec 将 FP16 ONNX 转换为 TensorRT Engine:
trtexec --onnx=best_fp16.onnx --fp16 --saveEngine=best_fp16.engine
然后使用第六部分提供的 YOLOv8TensorRT 类进行推理。通常情况下 TensorRT 比 ONNX Runtime GPU 再快 20%~50%。
注意事项:
-
Engine 必须在目标 GPU 上构建,不可跨 GPU 架构移植。
-
INT8 量化虽能更快,但可能对小目标或低对比度文字造成精度下降,需用测试集验证。
若不方便安装 TensorRT,保留 FP16 ONNX + onnxruntime-gpu 即可满足绝大多数产线需求。
8.3 系统流水线优化
主程序 main.py 中的采集线程已实现“只取最新帧”,不会堆积。以下额外优化可直接应用:
-
关闭界面显示:产线运行时无需
cv2.imshow,确保main.py中没有任何显示语句(本教程已满足)。 -
减少 Python GC 暂停:在
main.py开头添加import gc; gc.disable(),避免周期性垃圾回收导致抖动。定时手动调用(如每 1000 次推理)可防止内存无限制增长。 -
文件写入异步化:
logger.py中的数据库写入和图片保存是 I/O 操作,可能阻塞主循环。可使用 PythonThreadPoolExecutor将保存操作放入后台线程池。修改logger.py中的log方法:
from concurrent.futures import ThreadPoolExecutor
import threading
class ResultLogger:
def __init__(self, db_path="test_results.db", image_dir="failed_images"):
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self.lock = threading.Lock()
# ... 原有表创建 ...
self.executor = ThreadPoolExecutor(max_workers=2)
def log(self, sn, module, status, confidence=0.0, image=None):
def _write():
timestamp = datetime.datetime.now().isoformat()
image_path = ""
if image is not None and status in ("FAIL", "TIMEOUT", "LOW_CONF"):
filename = f"{sn}_{module}_{timestamp.replace(':','-')}.jpg"
image_path = os.path.join(self.image_dir, filename)
cv2.imwrite(image_path, image)
with self.lock:
self.cursor.execute("INSERT INTO results ...", (...))
self.conn.commit()
self.executor.submit(_write)
但需注意 SQLite 在多线程下需启用 check_same_thread=False 并使用锁。
-
图像预处理复用:若相机分辨率固定,可提前分配预处理用的 NumPy 数组,减少每次的内存分配。
8.4 长期稳定性测试(72 小时连续运行)
编写一个独立于主程序的长时间压力测试脚本 long_run_stability_test.py,它会连续循环运行模拟测试过程,并记录资源趋势。
import time
import yaml
import csv
from camera import CameraCapture
from detector import Detector
import psutil
import GPUtil
import numpy as np
from pathlib import Path
CONFIG_PATH = 'config.yaml'
LOG_FILE = 'stability_log.csv'
DURATION_HOURS = 72
def main():
config = yaml.safe_load(open(CONFIG_PATH))
cam = CameraCapture(
camera_id=config['camera']['id'],
width=config['camera']['width'],
height=config['camera']['height'],
exposure=config['camera']['exposure'],
auto_exposure=config['camera']['auto_exposure'],
auto_wb=config['camera']['auto_wb']
)
detector = Detector(
onnx_path=config['model']['onnx_path'],
conf_thres=config['model']['conf_threshold'],
iou_thres=config['model']['iou_threshold'],
class_thresholds=config.get('thresholds', {})
)
start_time = time.time()
end_time = start_time + DURATION_HOURS * 3600
with open(LOG_FILE, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['timestamp', 'inference_ms', 'gpu_mem_mb', 'cpu_percent', 'ram_percent'])
while time.time() < end_time:
frame = cam.get_frame()
if frame is None:
time.sleep(0.1)
continue
t0 = time.time()
detector.detect_filtered(frame)
infer_ms = (time.time() - t0) * 1000
# 每5秒记录一次资源
if int(time.time()) % 5 == 0:
gpu_mem = GPUtil.getGPUs()[0].memoryUsed if GPUtil.getGPUs() else 0
cpu = psutil.cpu_percent()
ram = psutil.virtual_memory().percent
writer.writerow([time.strftime('%H:%M:%S'), f"{infer_ms:.2f}", gpu_mem, cpu, ram])
time.sleep(0.2)
cam.release()
print("72小时稳定性测试结束,日志保存于", LOG_FILE)
if __name__ == '__main__':
main()
测试结束后,用 Excel 或 Python 绘制资源曲线。正常的系统应满足:
-
推理延迟波动 < ±5 ms
-
内存/显存占用趋于平坦,无持续上升(泄漏)
-
CPU 占用稳定,无周期性尖峰
泄漏排查:若发现内存持续增长,可在代码中引入 tracemalloc 定位。典型的泄漏源是未释放的 NumPy 数组或 OpenCV 图像未正确覆盖。
8.5 异常注入与容错测试
产线环境可能出现各种意外,系统必须具备一定自愈能力。以下测试需要手动干预或编写模拟器,但可验证系统的稳健性。
8.5.1 摄像头断连恢复
修改 camera.py 的 _grab 方法,增加自动重连逻辑(已在第七部分中给出基础版,此处强化):
def _grab(self):
while self.running:
ret, frame = self.cap.read()
if not ret:
print("[相机] 读取失败,5秒后尝试重新打开摄像头...")
self.cap.release()
time.sleep(5)
self.cap = cv2.VideoCapture(self.camera_id_original) # 需要保存原始 ID 和参数
# 重新应用所有参数
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
# ... 其他参数
continue
# 清空旧帧逻辑不变...
测试方法:运行主程序,拔掉摄像头 USB 线,5 秒后插回,程序应自动恢复检测,且日志中记录断连事件。
8.5.2 GPU 推理异常降级
在 detector.py 的 detect_filtered 中增加异常捕获,若 ONNX Runtime GPU 抛出异常,尝试切换至 CPU 后端并报警:
def detect_filtered(self, img_bgr):
try:
boxes, cls_ids, confs = self.detect(img_bgr)
except Exception as e:
print(f"[严重] 推理异常: {e},尝试切换 CPU 后端")
self.session.set_providers(['CPUExecutionProvider'])
boxes, cls_ids, confs = self.detect(img_bgr)
# ... 阈值过滤 ...
测试方法:临时卸载 GPU 驱动(仅测试环境!)或使用环境变量 CUDA_VISIBLE_DEVICES="" 模拟无 GPU 情况,程序应能降级并继续运行。
8.5.3 被测设备死机或卡屏
主程序已包含超时机制,若屏幕无变化,经过 timeout_per_module 秒后自动判为 TIMEOUT。测试方法:让被测设备停在某个界面不动,观察是否准时触发超时报警。
8.5.4 异常光照干扰
用手电筒照射屏幕或突然打开遮光罩,观察模型的置信度是否急剧下降,系统是否会误报。正常情况应出现低置信度 PASS 保存图片或判定为 UNKNOWN 持续等待。可结合第五部分调优的阈值提高抗干扰能力。
8.6 模型热更新与 A/B 测试
在不停机的情况下更换模型文件,并进行灰度验证。
实现方法:在 detector.py 中增加文件监控线程,当 best.onnx 文件被替换(修改时间变化)时自动重新加载模型。
import threading
import os
class Detector(YOLOv8ONNX):
def __init__(self, onnx_path, ...):
super().__init__(onnx_path, ...)
self.onnx_path = onnx_path
self.last_mtime = os.path.getmtime(onnx_path)
self.watch_thread = threading.Thread(target=self._watch, daemon=True)
self.watch_thread.start()
def _watch(self):
while True:
time.sleep(30)
try:
mtime = os.path.getmtime(self.onnx_path)
if mtime != self.last_mtime:
print("[模型] 检测到更新,重新加载...")
self.session = ort.InferenceSession(self.onnx_path, providers=self.providers)
self.last_mtime = mtime
except Exception as e:
print(f"[模型] 监控异常: {e}")
A/B 测试:先在一条线体上部署新模型 best_v2.onnx,同时运行旧模型作为备份。对比两工位的测试通过率、FAIL 检出率,确认无异常后全量替换。数据库记录中可添加 model_version 字段便于统计。
8.7 磁盘空间管理与日志轮转
长时间运行后,失败图片和数据库会占用大量磁盘空间。
自动清理脚本 cleanup.py(可加入定时任务):
import os
import time
from pathlib import Path
FAILED_IMG_DIR = 'failed_images'
DB_PATH = 'test_results.db'
MAX_DAYS = 30
def clean_images():
now = time.time()
for f in Path(FAILED_IMG_DIR).glob('*.jpg'):
if os.path.getmtime(f) < now - MAX_DAYS * 86400:
os.remove(f)
print(f"已删除过期图片: {f}")
def vacuum_db():
import sqlite3
conn = sqlite3.connect(DB_PATH)
conn.execute("VACUUM")
conn.close()
print("数据库已压缩")
if __name__ == '__main__':
clean_images()
vacuum_db()
配置到 crontab (Linux) 或任务计划程序 (Windows),每天凌晨执行一次。
8.8 性能优化清单与验收标准
| 优化项 | 目标值 | 实现方法 |
|---|---|---|
| 推理延迟 | < 50 ms | FP16 ONNX 或 TensorRT |
| 帧处理总时间 | < 100 ms | 异步采集,间隔 0.2 s |
| CPU 占用 | < 30% (4核) | 关闭显示,异步 I/O |
| 内存占用 | 72h 无持续增长 | 泄漏检查与 GC 控制 |
| 摄像头断连恢复 | 5s 内自动重连 | 重连逻辑 |
| 磁盘占用 | 保持 < 70% | 定时清理 |
最终验收:在 72 小时连续测试中,系统不应出现崩溃、死机、推理延迟突增;所有注入的异常均能正确捕获并报警;资源占用平稳,无泄漏。只有完全满足这些条件,才能放行进入量产部署。
第九部分 现场部署、校准与日常维护
需要经过严格测试的视觉系统安装到真实的产线工位上,并进行校准、验收和长期的维护管理。将得到完整的安装脚本、校准工具、验收标准和维护流程,确保每一台工控机都能达到与实验室相同的表现,并在数月内持续稳定运行。
9.1 工位硬件安装规范
9.1.1 硬件物料清单 (BOM)
| 序号 | 名称 | 规格要求 | 数量 |
|---|---|---|---|
| 1 | 工业相机 | USB3.0 接口,≥500 万像素,支持手动曝光/白平衡 | 1 |
| 2 | 定焦镜头 | 焦距根据物距和屏幕尺寸选择,如 12mm | 1 |
| 3 | 偏振片套件 | 镜头用 1 片,光源用 1 片,可旋转调节 | 1 套 |
| 4 | 环形 LED 光源 | 可调亮度,白色 | 1 |
| 5 | 相机支架 | 铝合金型材,带微调云台,可锁紧 | 1 |
| 6 | 设备定位治具 | 仿形设计,确保设备放置重复性 < 2mm | 1 |
| 7 | 遮光罩 | 茶色亚克力板或黑布,覆盖整个测试区域 | 1 |
| 8 | 工控机 | 含 NVIDIA GPU,内存 ≥ 8GB | 1 |
| 9 | 显示器、键鼠 | 调试用,调试完毕可移除 | 1 套 |
| 10 | 串口报警灯 | 可选,通过串口控制红绿指示灯 | 1 |
| 11 | 扫码枪 | 录入设备序列号(模拟键盘输入) | 1 |
9.1.2 安装步骤
-
固定治具:将仿形治具用螺栓或压板固定在测试台面上,确保被测设备放入时屏幕朝向相机,且每次放置位置一致。
-
安装相机:将相机支架锁在治具正前方,调整高度使镜头光轴与屏幕中心等高。微调云台后拧紧所有关节,用油漆笔在所有可调处画上防松标记。
-
布置光源与偏振片:环形 LED 固定在镜头前端或侧面。在光源前安装一片偏振片,镜头前安装另一片,旋转其中一片直到屏幕上的反光减至最弱,然后锁定偏振片角度。
-
搭建遮光罩:用茶色亚克力板封闭整个测试区,前方设可开合的门,门上加装磁控传感器(可选)。光线只能在遮光罩内部循环,隔绝环境光变化。
-
连接线缆:相机 USB 线、光源电源线、报警灯串口线、扫码枪 USB 线全部用线夹固定,预留缓冲弯,防止拉扯松动。
-
工控机上电:安装系统镜像或按下一节部署软件环境。
9.2 工控机软件环境一键部署
Ubuntu 和 Windows 需要分别准备了自动化部署脚本,确保所有工位软件环境完全一致。
9.2.1 Ubuntu 部署脚本
在任意一台已配置好的工控机上,将所有程序文件和依赖列表打包,然后通过 U 盘或网络复制到新工控机。部署脚本 deploy_ubuntu.sh 如下:
#!/bin/bash set -e echo "=== 量产视觉测试系统部署 (Ubuntu) ===" # 安装系统依赖 sudo apt update sudo apt install -y python3.10 python3.10-venv python3-pip libxcb-xinerama0 # 创建项目目录 sudo mkdir -p /opt/factory_test sudo chown $USER:$USER /opt/factory_test cd /opt/factory_test # 从 U 盘或网络复制程序包(假设挂载在 /media/usb) cp -r /media/usb/production_test/* . # 创建虚拟环境并安装依赖 python3.10 -m venv venv source venv/bin/activate pip install -r requirements.txt # 设置开机自启 sudo cp factory_test.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable factory_test.service sudo systemctl start factory_test.service echo "部署完成,服务已启动。"
factory_test.service 文件内容(位于程序包内):
[Unit] Description=Factory Test Vision System After=multi-user.target [Service] Type=simple WorkingDirectory=/opt/factory_test ExecStart=/opt/factory_test/venv/bin/python /opt/factory_test/main.py Restart=on-failure RestartSec=10 Environment=DISPLAY=:0 [Install] WantedBy=multi-user.target
9.2.2 Windows 部署步骤
-
将
production_test文件夹完整拷贝到C:\factory_test\。 -
安装 Miniconda,打开 Anaconda Prompt,创建环境:
conda create -n factory python=3.10 -y conda activate factory pip install -r requirements.txt
-
在
C:\factory_test\下创建启动批处理start.bat:@echo off call C:\Users\Admin\miniconda3\Scripts\activate.bat factory python C:\factory_test\main.py
-
配置开机自启:打开“任务计划程序”,创建基本任务,触发器选“计算机启动时”,操作选“启动程序”并指向
start.bat。 -
设置 Windows 自动登录:运行
netplwiz,取消勾选“要使用本计算机,用户必须输入用户名和密码”,输入自动登录的账户密码(仅限隔离产线网络)。
9.3 现场校准工具
安装完成后,必须对每台工位进行现场校准,确保图像质量和模型检测效果与训练时一致。
9.3.1 曝光自动调节与金样保存
脚本 calibrate_on_site.py(放在程序目录下,独立运行):
import cv2
import yaml
import numpy as np
import os
from detector import Detector
CONFIG_PATH = 'config.yaml'
GOLDEN_IMAGE = 'golden_sample.jpg'
GOLDEN_RESULT = 'golden_detection.jpg'
def main():
config = yaml.safe_load(open(CONFIG_PATH))
cam_id = config['camera']['id']
width = config['camera']['width']
height = config['camera']['height']
cap = cv2.VideoCapture(cam_id)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0)
cap.set(cv2.CAP_PROP_EXPOSURE, config['camera']['exposure'])
cap.set(cv2.CAP_PROP_AUTO_WB, 0)
detector = Detector(
onnx_path=config['model']['onnx_path'],
conf_thres=config['model']['conf_threshold'],
iou_thres=config['model']['iou_threshold'],
class_thresholds=config.get('thresholds', {})
)
print("=== 现场校准程序 ===")
print("请将金样设备(已知全部通过)放入治具,并确保测试程序运行在通过画面。")
print("按键说明:")
print(" a - 自动调整曝光(根据屏幕平均亮度)")
print(" s - 保存当前帧为金样基准,并运行检测验证")
print(" q - 退出")
while True:
ret, frame = cap.read()
if not ret:
print("相机无信号,请检查连接。")
break
cv2.imshow('Calibration', frame)
key = cv2.waitKey(1) & 0xFF
if key == ord('a'):
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
avg_brightness = np.mean(gray)
target = 128
new_exposure = config['camera']['exposure'] + int((target - avg_brightness) * 0.1)
new_exposure = max(-13, min(new_exposure, -1))
cap.set(cv2.CAP_PROP_EXPOSURE, new_exposure)
config['camera']['exposure'] = new_exposure
print(f"曝光已调整至 {new_exposure} (当前平均亮度 {avg_brightness:.0f})")
elif key == ord('s'):
cv2.imwrite(GOLDEN_IMAGE, frame)
boxes, cls_ids, confs = detector.detect_filtered(frame)
# 绘制检测框
disp = frame.copy()
class_names = ['pass_text','fail_text','module_label','camera_preview',
'audio_waveform','check_icon','cross_icon']
for box, cls_id, conf in zip(boxes, cls_ids, confs):
x1, y1, x2, y2 = map(int, box)
cv2.rectangle(disp, (x1,y1), (x2,y2), (0,255,0), 2)
cv2.putText(disp, f"{class_names[cls_id]}:{conf:.2f}", (x1,y1-5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,255,0), 1)
cv2.imwrite(GOLDEN_RESULT, disp)
print(f"金样已保存。检测到 {len(boxes)} 个目标,请手动检查 {GOLDEN_RESULT} 中框是否准确且置信度 > 0.8")
elif key == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
# 保存更新后的曝光值到配置文件
with open(CONFIG_PATH, 'w') as f:
yaml.dump(config, f)
print("校准结束,配置文件已更新。")
if __name__ == '__main__':
main()
操作流程:
-
运行
python calibrate_on_site.py。 -
按
a键自动调整曝光,直至画面亮度适中、文字清晰无反光。 -
放置一台已知所有模块均通过的样机(称为金样设备),启动测试程序并停留在某个典型通过画面。
-
按
s键保存金样图像和检测结果,目视确认所有该出现的 PASS 文字、模块标签、图标均被正确框出,且置信度 > 0.8。如有遗漏或误检,需排查相机位置、光照或模型。 -
按
q退出,新的曝光值将写入config.yaml。
9.3.2 金样自动自检脚本
校准完成后,创建一个每日点检脚本 golden_test.py,操作员只需运行它,系统自动拍摄并比对金样,确认检测结果与基准一致。
import cv2
import yaml
import numpy as np
from detector import Detector
CONFIG_PATH = 'config.yaml'
GOLDEN_IMAGE = 'golden_sample.jpg'
EXPECTED_CLASSES = [0, 2] # 根据金样画面,应出现的类别 ID,例如 pass_text 和 module_label
def main():
config = yaml.safe_load(open(CONFIG_PATH))
cap = cv2.VideoCapture(config['camera']['id'])
cap.set(cv2.CAP_PROP_FRAME_WIDTH, config['camera']['width'])
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, config['camera']['height'])
cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0)
cap.set(cv2.CAP_PROP_EXPOSURE, config['camera']['exposure'])
detector = Detector(
onnx_path=config['model']['onnx_path'],
conf_thres=config['model']['conf_threshold'],
iou_thres=config['model']['iou_threshold'],
class_thresholds=config.get('thresholds', {})
)
input("请放入金样设备并启动测试,按 Enter 开始自检...")
for i in range(10): # 连续采样10帧
ret, frame = cap.read()
if not ret:
continue
boxes, cls_ids, confs = detector.detect_filtered(frame)
detected_classes = set(cls_ids.astype(int))
if set(EXPECTED_CLASSES).issubset(detected_classes):
print("金样自检通过!")
cap.release()
return
cv2.waitKey(200)
print("警告:金样自检失败!请检查工位状态。")
cap.release()
if __name__ == '__main__':
main()
将此脚本加入每日开班点检流程,操作员只需运行一次,确认输出“通过”即可。
9.4 验收测试
在工位正式移交生产前,需用已知结果的设备进行严格验收。
9.4.1 验收标准
-
正常设备(5 台,所有模块 PASS):每台连续测试 3 次,全部模块必须判为 PASS,无 TIMEOUT 或 FAIL。
-
故障设备(5 台,分别制造 RTC/摄像头/音频/WiFi/按键单项 FAIL):每台测试 3 次,对应模块必须判为 FAIL,其他模块 PASS。
-
系统稳定性:连续运行 2 小时,无 crash、无异常资源增长。
9.4.2 验收执行
直接使用第七部分的 main.py 进行测试,但将数据库改为独立的 validation.db,避免污染生产数据。修改 config.yaml 中的 database.path 为 validation.db,或直接在命令行指定。
操作员逐台放入设备,输入序列号(可使用扫码枪),观察测试结果并记录。所有 FAIL 必须准确触发报警,所有 PASS 必须绿灯指示。
9.5 日常维护与点检
9.5.1 操作员每班点检清单
| 项目 | 方法 | 频次 |
|---|---|---|
| 清洁镜头、偏振片 | 无尘布蘸少量酒精轻擦 | 每班 |
| 清洁治具及屏幕表面 | 干布擦拭 | 每班 |
| 金样自检 | 运行 python golden_test.py,确认通过 |
每班 |
| 检查遮光罩密封 | 目视,关门后无明显缝隙漏光 | 每日 |
| 检查相机防松标记 | 观察油漆标记是否错位 | 每周 |
| 磁盘空间检查 | 运行清理脚本或查看磁盘余量 | 每周 |
9.5.2 自动磁盘清理
将第八部分的 cleanup.py 部署到工控机,并加入 Linux 的 crontab 或 Windows 的任务计划,每日凌晨 3 点执行,删除 30 天前的失败图片,并压缩数据库。
Linux crontab 示例:
0 3 * * * /opt/factory_test/venv/bin/python /opt/factory_test/cleanup.py
9.6 远程监控与报警
如果工控机在局域网内,可部署一个极简的 HTTP 状态接口,供监控平台定时拉取。
在程序包中添加 health_server.py:
from flask import Flask, jsonify
import psutil
import GPUtil
import os
app = Flask(__name__)
@app.route('/health')
def health():
gpu = GPUtil.getGPUs()[0] if GPUtil.getGPUs() else None
return jsonify({
'cpu_percent': psutil.cpu_percent(),
'ram_percent': psutil.virtual_memory().percent,
'gpu_mem_used': gpu.memoryUsed if gpu else 0,
'disk_percent': psutil.disk_usage('/').percent,
'camera_ok': os.path.exists('/dev/video0') # Ubuntu 示例
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
在 main.py 启动时,用线程启动该服务,或单独作为后台进程运行。监控平台(如 Zabbix、Prometheus)定时访问 http://工控机IP:5000/health,异常时发出邮件/短信通知。
9.7 模型衰退监控与再训练
9.7.1 衰退检测指标
在数据库中统计每日的 LOW_CONF 比例 和 人工复检发现的误判数。如果 LOW_CONF 占比连续三天超过 5%,或某类 FAIL 的检出率突然下降(通过对比坏样测试记录),则触发模型更新。
查询 LOW_CONF 比例的 SQL 示例(在 logger.py 中记录时,LOW_CONF 是作为一种 status 写入的):
SELECT COUNT(*) FROM results WHERE status='LOW_CONF' AND timestamp > date('now','-7 day');
9.7.2 数据回流与模型迭代
-
收集:FAIL 图片、LOW_CONF 图片、随机抽样的 1% 正常图片自动保存在工控机,通过
scp或共享文件夹定期上传到训练服务器。 -
标注:算法工程师使用 labelImg 对新图片进行修正标注(尤其 FAIL 和 LOW_CONF 中误判的框)。
-
合并:将新标注的数据加入原训练集(备份原数据集),运行
split_dataset.py重新划分。 -
重训:执行第四部分的
train.py,生成新模型best_v2.onnx。 -
验证:在测试集上评估新模型,确保 mAP 不下降,尤其 FAIL 类 Recall 保持 0.99 以上。
-
灰度更新:先在一条产线更新模型,运行数天,对比旧模型性能,无问题后全量推送。
-
回滚:保留上一版模型文件,一旦新模型表现异常,可通过修改
config.yaml中的onnx_path快速回滚,无需重新部署。
9.8 风险与对策总结
| 风险 | 影响 | 应对 |
|---|---|---|
| 治具松动导致屏幕偏移 | 模型检测框位置偏移,漏检增加 | 用防松标记,每周点检;高级方案:在治具上贴 ArUco 码,脚本自动检测位置 |
| 屏幕亮度衰减 | 文字对比度下降,低置信度增加 | 训练数据增强时增加大范围亮度变化;定期更新金样基准 |
| 工控机硬件故障 | 停线 | 准备一台完全配置好的备用工控机,定期用 Clonezilla 备份系统盘 |
| 操作员误操作 | 未放好设备、开门干扰 | 在界面增加操作提示,门磁传感器联动暂停测试 |
| 模型文件损坏或误删 | 系统无法推理 | 设置文件权限只读,备份在专用目录 |
第十部分 边缘案例处理、持续改进与项目总结
10.1 屏幕动态效果与转场动画的自动抑制
被测设备在切换测试模块时,常出现淡入淡出、滑动转场等动画,画面瞬间模糊或元素缺失,极易引发误判。通过帧间差异稳定性检测,只在画面稳定时才进行推理。
10.1.1 稳定性检测类
将以下类嵌入 main.py 中(或作为独立模块),在获取每一帧后、调用 detect_filtered 前检查画面稳定性。
import cv2 import numpy as np class StabilityChecker: """ 检测连续两帧之间的像素变化比例,仅当变化低于阈值时认为画面稳定。 """ def __init__(self, threshold=0.08): self.last_gray = None self.threshold = threshold # 像素变化比例阈值(0~1),越小越严格 def is_stable(self, frame_bgr): gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) if self.last_gray is None: self.last_gray = gray return False # 第一帧不判定 # 计算像素变化比例(差异大于10的像素数占总像素比例) diff = cv2.absdiff(gray, self.last_gray) change_ratio = np.count_nonzero(diff > 10) / diff.size self.last_gray = gray return change_ratio < self.threshold
集成方法:在 main.py 的模块测试循环中,获取帧后立即调用稳定性检查:
stab_checker = StabilityChecker(threshold=0.08) while time.time() - start_time < timeout: frame = cam.get_frame() if frame is None: time.sleep(0.05) continue # 跳过不稳定帧(转场动画等) if not stab_checker.is_stable(frame): continue boxes, cls_ids, confs = detector.detect_filtered(frame) # ... 后续判决逻辑
原理:转场动画会导致大量像素在短时间内剧烈变化,change_ratio 会显著升高。稳定后再判决,可消除 95% 以上的动画干扰。
风险与调整:阈值 threshold 需根据实际相机噪声水平微调。如果相机本身噪点较多,可适当提高阈值(如 0.12),否则可能一直跳过无法进入稳定状态。在生产环境下,0.05~0.1 通常是安全范围。
10.2 半透明弹窗与遮挡物处理
半透明警告框(如“电量低”、“网络断开”)会降低 PASS/FAIL 文字的置信度,导致漏检或低置信度通过。在视觉检测置信度处于模糊区间时,引入轻量 OCR 二次确认。
10.2.1 安装 Tesseract OCR
Ubuntu:
sudo apt install tesseract-ocr tesseract-ocr-eng -y pip install pytesseract
Windows: 下载 Tesseract 安装程序 并安装,记住安装路径(例如 C:\Program Files\Tesseract-OCR),然后在代码中指定路径。
10.2.2 OCR 二次确认函数
import pytesseract # Windows 下需指定 tesseract.exe 路径(若未加入 PATH) # pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe' def ocr_verify(image_bgr, box_xyxy): """截取图像中的指定区域,返回 OCR 识别出的文本(大写)""" x1, y1, x2, y2 = map(int, box_xyxy) roi = image_bgr[y1:y2, x1:x2] # 转灰度提高识别率 gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) # 二值化处理 _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) text = pytesseract.image_to_string(thresh, config='--psm 7').strip().upper() return text
判决增强:在 check_module 或其他自定义检查函数中,当出现 pass_text 但置信度 < 0.6 时,调用 OCR 再次确认。
# 假设已有 boxes, cls_ids, confs has_pass_visually = False pass_conf = 0.0 pass_box = None for box, cls_id, conf in zip(boxes, cls_ids, confs): if cls_id == 0: # pass_text if conf > pass_conf: pass_conf = conf pass_box = box has_pass_visually = True if has_pass_visually and pass_conf < 0.6: ocr_text = ocr_verify(frame, pass_box) if "PASS" not in ocr_text: has_pass_visually = False # OCR 不认可,驳回通过
这样即使半透明遮挡导致视觉模型犹豫,OCR 也能给出最终裁决。
风险:OCR 本身也有误识别率,特别是低分辨率小文字。仅当视觉模型给出中等置信度时才启用,以减少误判。--psm 7 表示单行文本处理,适合 PASS/FAIL 这样的短词。
10.3 文字残缺与模糊的模型增强
屏幕排线松动、轻微失焦等会导致文字模糊或笔画断裂。通过在训练数据上施加特定增强,可让模型学会识别这类缺陷文字。
10.3.1 离线增强脚本(推荐)
由于 Ultralytics 的在线增强无法精确控制模糊、残缺等形态,采用离线方式增强训练集。以下脚本读取训练集图片,对每张图片生成 2~3 个增强版本,并直接复制对应标注文件(因为目标位置不变)。
脚本 augment_dataset.py:
import cv2
import albumentations as A
from pathlib import Path
import random
import shutil
TRAIN_IMG_DIR = Path('datasets/factory_test/train/images')
TRAIN_LBL_DIR = Path('datasets/factory_test/train/labels')
OUTPUT_IMG_DIR = Path('datasets/factory_test/train/images_augmented')
OUTPUT_LBL_DIR = Path('datasets/factory_test/train/labels_augmented')
OUTPUT_IMG_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_LBL_DIR.mkdir(parents=True, exist_ok=True)
# 定义增强管道,随机施加模糊、降采样、遮挡
transform = A.Compose([
A.OneOf([
A.GaussianBlur(blur_limit=(3, 7), p=0.5),
A.MotionBlur(blur_limit=(3, 7), p=0.5),
A.Downscale(scale_min=0.5, scale_max=0.9, interpolation=cv2.INTER_LINEAR, p=0.5),
], p=0.6),
A.CoarseDropout(max_holes=2, max_height=15, max_width=15, fill_value=0, p=0.3),
A.RandomBrightnessContrast(brightness_limit=0.3, contrast_limit=0.3, p=0.5),
])
for img_path in TRAIN_IMG_DIR.glob('*.jpg'):
lbl_path = TRAIN_LBL_DIR / (img_path.stem + '.txt')
if not lbl_path.exists():
continue
img = cv2.imread(str(img_path))
if img is None:
continue
# 生成 2 个增强版本
for aug_idx in range(2):
augmented = transform(image=img)['image']
new_name = f"{img_path.stem}_aug{aug_idx}.jpg"
cv2.imwrite(str(OUTPUT_IMG_DIR / new_name), augmented)
# 标注文件直接复制(因为目标相对位置未变,几何未改变)
shutil.copy2(lbl_path, OUTPUT_LBL_DIR / (new_name.replace('.jpg', '.txt')))
print("增强完成,请将增强后的图片和标注合并到原训练集中。")
# 手动或再运行合并步骤:将 augmented 文件夹内容移动到 train/images 和 train/labels
运行后,将 OUTPUT_IMG_DIR 和 OUTPUT_LBL_DIR 中的文件剪切到原始的 train/images 和 train/labels 中即可。务必先备份原始数据。
原理:增强图片保留了原标注,因为只改变了像素外观,目标几何位置和类别不变。模型将被迫学习形状而不是依赖清晰纹理。
10.3.2 注意事项
-
增强倍数不宜过大(2~3 倍足矣),否则训练时间倍增且可能引入过多重复模式。
-
如果担心增强后标注框略微偏移(如降采样可能导致定位偏差),可对标注也做微调,但 YOLO 本身对标注有一定容错,通常无需处理。
10.4 固定反光光斑的位置屏蔽
如果产线遮光不完美,某个固定角落总有反光光斑被误判为 PASS 图标,可在后处理阶段直接屏蔽该区域内的任何检测框。
10.4.1 配置文件添加屏蔽区域
在 config.yaml 中增加:
exclude_regions: - [0, 0, 120, 80] # 左上角区域 [x1, y1, x2, y2],坐标相对于原图分辨率 - [1700, 900, 1920, 1080] # 右下角
10.4.2 修改 detector.py 的过滤逻辑
在 detect_filtered 方法中返回结果前,调用新增的屏蔽函数:
def apply_exclude_regions(boxes, cls_ids, confs, regions, img_w, img_h): if not regions: return boxes, cls_ids, confs keep = [] for i, box in enumerate(boxes): # box 是 xyxy 像素坐标 cx = (box[0] + box[2]) / 2 cy = (box[1] + box[3]) / 2 in_exclude = False for rx1, ry1, rx2, ry2 in regions: if rx1 <= cx <= rx2 and ry1 <= cy <= ry2: in_exclude = True break if not in_exclude: keep.append(i) if keep: return boxes[keep], cls_ids[keep], confs[keep] return np.array([]), np.array([]), np.array([])
然后在 detect_filtered 末尾调用:
boxes, cls_ids, confs = self.apply_exclude_regions(boxes, cls_ids, confs, self.exclude_regions, img_w, img_h)
其中 self.exclude_regions 从配置加载并传入。
风险:如果屏蔽区域过大或位置不准,可能误屏蔽真正的目标,需在部署时精确测量光斑位置并留出安全边界。
10.5 多语言 UI 的适配
出口设备可能显示中文“通过”、英文“PASS”、日文“合格”等。最佳方案是要求研发统一使用图标(绿勾/红叉),但如果已无法变更,可采用 OCR 后处理来适配。
10.5.1 多语言字典
PASS_SYNONYMS = ["PASS", "通过", "合格", "OK", "SUCCESS"] FAIL_SYNONYMS = ["FAIL", "失败", "NG", "ERR", "ERROR"]
在 OCR 验证函数中(10.2 节),检查识别结果是否包含字典中的任一关键词:
def is_pass_ocr(ocr_text): return any(word in ocr_text for word in PASS_SYNONYMS) def is_fail_ocr(ocr_text): return any(word in ocr_text for word in FAIL_SYNONYMS)
视觉模型仍然只检测通用的文字区域(pass_text 类别),再由 OCR 根据字典判断具体状态。这样可以大大减少模型类别的数量,提高泛化能力。
10.6 主动学习数据闭环(半自动化)
让系统在运行中自动收集难例,是模型持续进化的核心。
10.6.1 收集异常数据
在 logger.py 中,除了记录 FAIL 和 LOW_CONF,还可随机保存少量正常图片作为“多样化背景”。此处提供一个定时脚本,从数据库读取近 7 天的异常记录,将对应图片复制到 retrain_raw 文件夹。
脚本 collect_retrain_data.py:
import sqlite3
import shutil
import os
from datetime import datetime, timedelta
DB_PATH = 'test_results.db'
FAIL_IMG_DIR = 'failed_images'
RETRAIN_DIR = 'retrain_raw'
os.makedirs(RETRAIN_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
since = (datetime.now() - timedelta(days=7)).isoformat()
cursor.execute("SELECT image_path FROM results WHERE status IN ('FAIL','LOW_CONF','TIMEOUT') AND timestamp > ? AND image_path != ''", (since,))
rows = cursor.fetchall()
for row in rows:
src = row[0]
if os.path.exists(src):
dst = os.path.join(RETRAIN_DIR, os.path.basename(src))
if not os.path.exists(dst):
shutil.copy2(src, dst)
print(f"已复制 {src} -> {dst}")
conn.close()
print("异常图片收集完毕。")
此脚本可每周运行一次,自动汇集所有可疑图片。
10.6.2 人工修正与重训流程
-
筛选与标注:用 LabelImg 打开
retrain_raw文件夹,检查每一张图片,修正错误的检测框(例如将误检的框删除,漏检的补上)。 -
合并数据集:将修正后的图片和标注文件复制到
datasets/factory_test/train/images和labels中。可直接用以下命令(先备份原数据集):cp retrain_raw/*.jpg datasets/factory_test/train/images/ cp retrain_raw/*.txt datasets/factory_test/train/labels/
-
重新划分验证集:运行第三部分的
split_dataset.py(或保持原有 val,仅扩充 train),使验证集包含部分新数据。 -
重训练:执行
train.py,可指定新名称如factory_test_v2。 -
评估与部署:使用第五部分流程评估新模型,确认无误后通过第七部分的热更新机制部署。
数据闭环的宗旨:只把模型犯错的样本加回来学习,效率最高,避免数据无止境膨胀。
10.7 持续改进的其他关键点
10.7.1 数据增强的持续进化
随着部署场景增多,可逐步引入更激进的增强:
-
随机擦除:模拟屏幕坏线、灰尘遮挡。
-
颜色抖动增强:应对不同批次的屏幕色温差异。
这些可在训练配置中直接调整 hsv_h, hsv_s, hsv_v 参数,或加入 CoarseDropout 等在线增强。
10.7.2 模型升级
当累积数据量增大后,可尝试:
-
使用更大的 YOLOv8m/l 提高上限;
-
升级到更新的 YOLO 版本(如 v9、v10 或未来的版本),Ultralytics 库通常直接兼容。
但要时刻评估推理延迟,避免超出产线节拍。
10.7.3 集成与仲裁
对于极其关键的 FAIL 检测,可部署两个不同的模型(例如一个检测文字,一个检测图标),结果取“或”逻辑(任一判 FAIL 即报 FAIL),极大降低漏检风险。但这会增加系统复杂度和硬件成本,需权衡。
简单实现:在 detector.py 中加载两个 ONNX 模型,分别推理然后合并结果,代码结构类似单模型。
10.8 项目文件
yolo_factory_test/ ├── 1_requirements/ # 项目定义 │ ├── classes.txt │ └── 项目配置清单.txt ├── 2_environment/ # 环境搭建(脚本存档) │ └── install_notes.md ├── 3_data/ # 数据采集与标注 │ ├── capture_calibrate.py │ ├── collect_dataset.py │ ├── split_dataset.py │ ├── validate_labels.py │ └── visualize_bboxes.py ├── 4_train/ │ └── train.py ├── 5_evaluate/ │ ├── data_test.yaml │ ├── evaluate_model.py │ ├── error_analysis.py │ └── tune_threshold.py ├── 6_deploy/ │ ├── export_onnx.py │ ├── yolo_onnx_inference.py │ ├── yolo_trt_inference.py (可选) │ └── benchmark_baseline.py ├── production_test/ # 实际部署的工控程序 │ ├── config.yaml │ ├── main.py │ ├── camera.py │ ├── detector.py │ ├── logger.py │ ├── alarm.py │ ├── yolo_onnx_inference.py # 推理核心 │ ├── best.onnx │ ├── calibrate_on_site.py │ ├── golden_test.py │ ├── cleanup.py │ ├── health_server.py │ └── collect_retrain_data.py └── datasets/factory_test/ # 数据集 ├── data.yaml ├── train/ └── val/
核心思想:AI 视觉检测绝不是“训练一个模型丢上去”就结束了。它需要从数据源头把控质量(第三部分),在训练中科学调参(第四部分),在部署前严苛测试(第五部分),在产线上闭环反馈(第十部分)。真正决定系统可靠性的,往往是工程化细节(相机锁定、稳定性检测、阈值调优),而非模型本身。
10.9 结语与风险
-
安全冗余:对于安全等级高的产品(如医疗、汽车),视觉检测只能作为辅助,最终放行权应留给人工或多传感器融合,防止系统性失效。
-
操作员依赖:再智能的系统也不能完全替代人工抽检,必须定期用故障样机验证系统有效性。
-
成本:工业相机、工控机、遮光治具等硬件投入可能超预期,需在项目初期做好预算。
-
团队技能:维护该系统需要掌握 Python、Linux 基本操作和深度学习基础知识的人员,建议提前安排培训。
更多推荐

所有评论(0)