ARTICLE DETAIL

资讯详情

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

基于UNet与YOLO的仪表读数方案:从OpenCV预处理到角度拟合实战

基于UNet与YOLO的仪表读数方案:从OpenCV预处理到角度拟合实战 简介这份文档面向从事计算机视觉与深度学习落地的开发者聚焦仪表自动读数这一典型工业场景系统梳理了从检测、分割到读数计算的完整方案流程与踩坑经验。内容围绕YOLOv5s提取仪表、Deeplab分割刻度、YOLOv5x提取指针以及圆心角法与边计算法求读数比例展开并针对指针分割失效、刻度拟合偏差、透视畸变等特例问题给出替代思路与优化记录适合需要搭建仪表识别pipeline的中高级读者参考。资源包共1个docx文件约2.86MB以图文方案说明为主便于按流程对照理解各阶段模型选型与参数设置。目前已有935人学习下载读者可从中获取完整的算法链路设计、模型训练轮次经验、读数误差控制方法及问题排查思路对复现与改进仪表识别项目具有直接参考价值。1. 仪表(更新).docx从一份文档到一套可复现的仪表读数方案手里拿到一份叫「仪表(更新).docx」的文件大概率不是让你读文档而是让你把文档里描述的那套仪表识别逻辑跑起来。仪表读数这件事说穿了就是把指针表、数字表、液位计这些工业场景里的表盘从一张图变成一串能进数据库的数字。传统做法靠模板匹配和霍夫直线换个光照、换个表盘型号就翻车现在主流方案是先用 OpenCV 做预处理和表盘定位再上 UNet 或 YOLO 系列做分割与检测最后做角度拟合或字符识别。这套组合能解决变电站巡检、化工厂抄表、水表电表远程读数这类重复劳动适合有 Python 基础、想用开源工具搭一套原型的一线工程师。下面按「先立住原理、再动手复现、最后说坑」的顺序拆开讲。2. 仪表读数方案选型UNet、YOLO5s 和 OpenCV 各自站哪一班岗仪表识别不是单一模型能包圆的活。一张表盘图进来要经过表盘定位、指针/数字区域分割、读数换算三个阶段每个阶段对工具的要求完全不同。选型选错后面调参调到怀疑人生。2.1 为什么分割优先选 UNet 而不是 DeepLab指针分割是仪表读数里最核心的一步。指针细、对比度低、还经常被反光干扰语义分割模型要能把指针从表盘背景里抠出来。UNet 的编码器-解码器结构加跳跃连接对小目标细长结构的边缘保留明显好于 DeepLab 系列。DeepLab 依赖空洞卷积和 ASPP 模块感受野大但边缘容易糊指针尖端经常被吃掉几个像素换算成角度就是好几度的误差。我一般这么分表盘背景干净、指针粗细均匀UNet 够用且训练快表盘有复杂刻度线干扰、指针和刻度颜色接近才考虑 DeepLabV3 加注意力模块。YOLO5s 和 YOLO5x 在这里的角色是检测表盘位置和数字区域不是做像素级分割。5s 轻量适合边缘设备5x 精度高适合服务器端。OpenCV 则贯穿始终负责图像增强、轮廓提取、直线拟合这些传统但稳定的操作。提示不要一上来就上大模型。先用 OpenCV 把表盘 ROI 裁出来再送进分割网络输入尺寸降下来UNet 的训练时间和显存占用都会好看很多。2.2 用 OpenCV 做表盘定位与预处理的完整命令拿到原图先别急着送网络。工业相机拍的图往往有畸变、光照不均、背景杂物直接训练收敛慢。下面这段代码做三件事灰度化加自适应直方图均衡、霍夫圆检测定位表盘、透视变换把表盘摆正。import cv2 import numpy as np def locate_gauge(img_path): img cv2.imread(img_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # CLAHE 对抗光照不均clipLimit 控制对比度增强幅度 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray clahe.apply(gray) # 高斯模糊去噪核大小必须是奇数 blur cv2.GaussianBlur(gray, (5, 5), 0) # 霍夫圆检测param2 越小越容易检出但误检也多 circles cv2.HoughCircles( blur, cv2.HOUGH_GRADIENT, dp1, minDist200, param1100, param230, minRadius80, maxRadius400 ) if circles is None: return None circles np.round(circles[0]).astype(int) x, y, r circles[0] # 裁出表盘外接正方形留 10% 余量 pad int(r * 0.1) x1, y1 max(0, x - r - pad), max(0, y - r - pad) x2, y2 min(img.shape[1], x r pad), min(img.shape[0], y r pad) roi img[y1:y2, x1:x2] return cv2.resize(roi, (512, 512)) if __name__ __main__: result locate_gauge(gauge_01.jpg) if result is not None: cv2.imwrite(gauge_roi.png, result)clipLimit设 2.0 是经验值太高会把噪声也放大param2从 30 开始试检不出就降到 20误检多就升到 40。minRadius和maxRadius必须根据你的相机工作距离来定设宽了检测速度会掉。裁出来的 ROI 统一 resize 到 512×512是为了后面 UNet 输入尺寸固定省得每次动态调整。2.3 UNet 训练自己的仪表数据集标注、增强和损失函数UNet 要训得好标注质量比网络结构重要。指针分割的标注只有两类背景和指针。用 LabelMe 或 CVAT 标导出成 PNG 掩码像素值 0 和 1。数据集不用太大一个表型 200 到 300 张就够但要覆盖不同光照和指针角度。数据增强用 albumentations重点做旋转、亮度对比度扰动、高斯噪声。旋转角度范围设 -15 到 15 度因为表盘安装一般不会歪太多。损失函数用 Dice Loss 加 BCE 的组合Dice 管区域重叠BCE 管像素分类两者权重各 0.5。优化器 Adam学习率 1e-3余弦退火到 1e-5batch size 根据显存设 4 或 8。import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.Rotate(limit15, border_modecv2.BORDER_CONSTANT, value0), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2, p0.5), A.GaussNoise(var_limit(10.0, 50.0), p0.3), A.Resize(512, 512), ToTensorV2() ])value0保证旋转填充的是背景不会引入假指针。var_limit控制噪声强度太大反而让模型学偏。训练时每 10 个 epoch 存一次权重看验证集 Dice 系数到 0.85 以上基本可用。3. 从分割掩码到读数指针角度拟合与数字识别落地分割出指针只是中间产物最终要的是读数。指针表靠角度换算数字表靠 OCR。两条路的技术栈不一样分开说。3.1 指针角度拟合最小外接矩形加斜率计算UNet 输出的掩码是二值图指针是一块白色区域。用 OpenCV 的minAreaRect拿到指针的最小外接矩形矩形的长边方向就是指针方向。再算这个方向和表盘零刻度方向的夹角按量程线性映射成读数。def mask_to_angle(mask): # mask 是 0/1 单通道图先转 uint8 mask (mask * 255).astype(np.uint8) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None # 取面积最大的轮廓排除噪点 cnt max(contours, keycv2.contourArea) rect cv2.minAreaRect(cnt) (cx, cy), (w, h), angle rect # minAreaRect 的 angle 在 [-90, 0)统一到 [0, 180) if w h: angle angle 90 else: angle angle return angle % 180cv2.contourArea用来过滤小噪点面积小于 50 像素的直接丢。minAreaRect返回的 angle 有方向歧义宽高比决定要不要加 90 度。算出来的角度还要减去零刻度偏移这个偏移在标定时确定每个表型不一样。3.2 数字仪表识别OpenCV 字符分割加轻量 OCR数字表没有指针直接对数字区域做识别。先用 YOLO5s 检测数字区域裁出来后做二值化和字符分割再送 OCR。OpenCV 在这里做字符切分比端到端 OCR 稳因为数字字体固定、背景干净。def split_digits(roi): gray cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 水平投影找字符间隙 proj np.sum(binary, axis0) gaps np.where(proj 0)[0] # 按间隙切分合并过窄的碎片 digits [] start 0 for i in range(1, len(proj)): if proj[i] 0 and proj[i-1] 0: start i elif proj[i] 0 and proj[i-1] 0: if i - start 5: digits.append(binary[:, start:i]) return digitsTHRESH_OTSU自动找阈值适合光照均匀的场景。投影法切字符对七段数码管特别有效间隙清晰。切出来的每个字符 resize 到 28×28送进一个简单的 CNN 分类器10 个数字类别训练数据用字体渲染加少量实拍就行。3.3 读数后处理中值滤波和量程映射单帧读数会跳工业场景要的是稳定值。连续采 10 帧取中值再按量程映射。映射公式是reading (angle - zero_angle) / (full_angle - zero_angle) * range。zero_angle和full_angle在标定阶段用已知读数反推。注意角度拟合出来的值在指针接近零刻度时会有跳变因为 0 度和 180 度在模运算下是同一个方向。处理办法是判断指针尖端到圆心的距离取距离大的那一端作为方向。4. 仪表识别避坑记录从环境配置到模型部署的五个翻车点这一章全是血泪经验每条都是实际项目里踩过的。4.1 ModuleNotFoundError: No module named opencv现象装完 OpenCV 跑代码报找不到 cv2。原因包名和导入名不一致pip install opencv装的是空壳真正要装opencv-python或opencv-contrib-python。解决卸载重装pip uninstall opencv opencv-python然后pip install opencv-contrib-python。如果要用 SIFT 和 CUDA 加速必须装 contrib 版本。4.2 OpenCV 报错 contourarea() 未定义标识符现象C 里调contourArea编译不过。原因头文件没包含全或者命名空间没写对。解决确认#include opencv2/imgproc.hpp调用写cv::contourArea(cnt)。Python 里不会出这个问题但 C 项目里经常漏。4.3 UNet 训练损失不降Dice 一直在 0.3 徘徊现象训练几十个 epoch损失不动。原因标注掩码像素值是 0 和 255没归一化到 0 和 1Dice 计算时被大值主导。解决读掩码后除以 255或者用ToTensorV2自动归一化。另外检查学习率1e-3 不降就试 1e-4。4.4 指针分割结果有空洞角度拟合偏差大现象掩码里指针中间断成几截。原因指针反光导致局部像素被分成背景。解决训练时加反光增强推理时对掩码做形态学闭运算核大小 3×3 或 5×5把断口连上。闭运算后再取最大轮廓。4.5 树莓派上 OpenCV 读取摄像头帧率只有 5 帧现象部署到边缘设备后实时性不够。原因默认用cv2.VideoCapture走的是高分辨率解码CPU 扛不住。解决设cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)和cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)降分辨率。再不行就用cv2.CAP_PROP_FOURCC设成 MJPG 格式硬解码省 CPU。5. 仪表读数方案的验证与迭代一套可量化的评估习惯方案搭完不算完得知道它到底行不行。我一般建一个 50 到 100 张的测试集覆盖不同光照、不同表型、不同指针角度每张图人工标好真实读数。评估分三层分割层看 Dice 和 IoU读数层看绝对误差和相对误差系统层看连续 100 帧的读数标准差。分割层 Dice 低于 0.8 就别看读数了先回去调分割。读数层相对误差控制在 1% 以内算合格工业场景要求 0.5%。系统层标准差大于满量程的 0.2% 说明后处理滤波不够加大中值滤波窗口或者加卡尔曼滤波。迭代方向有三个一是补难例把测试集里误差大的图挑出来重新标注加进训练集二是换 backboneUNet 换成 ResNet34 或 EfficientNet 编码器精度能涨两三个点但推理变慢三是量化部署用 ONNX Runtime 或 TensorRT 把模型压到 FP16树莓派上帧率能翻倍。def evaluate(pred_readings, gt_readings, full_range): errors [abs(p - g) for p, g in zip(pred_readings, gt_readings)] mae np.mean(errors) mre np.mean([e / full_range for e in errors]) std np.std(pred_readings) print(fMAE: {mae:.4f}, MRE: {mre:.4%}, STD: {std:.4f}) return mae, mre, stdfull_range是仪表量程比如 0 到 100 就填 100。MRE 用相对量程的比值比相对真实值的比值更稳因为真实值可能接近零导致分母爆炸。我自己的习惯是每改一版模型先把测试集跑一遍三个指标记在表格里涨了才留跌了就回滚。别凭感觉说「好像准了」数据不会骗人。这套流程跑顺了换个新表型也就是重新标 200 张图的事。希望帮到你。本文还有配套的精品资源点击获取
返回列表