
简介一份基于YOLOv5与DeepLabV3Plus的C仪表识别项目专注解决指针式仪表的检测、表盘分割与刻度读数问题适合计算机视觉方向的毕设、课设或入门进阶实践。压缩包共11个文件以cpp源文件和h头文件为主另有CMakeLists构建配置与README说明整体仅22KB代码结构精炼便于快速阅读和二次开发。项目代码经过运行验证下载后若运行遇到困难作者提供远程教学支持对小白友好。目前已有126人学习下载适合自动化、电子信息等专业学生作为项目起步参考也可在现有模型基础上改动扩展更多仪表类型识别场景。整体来看这套源码提供了一条从目标检测到分割再到读数识别的完整技术链路配套文档能帮助理解工程组织方式是低成本上手视觉识别项目的实用素材。1. 仪表自动读数项目怎么落地YOLOv5定位表盘、DeepLabV3Plus分割指针、C串起整体推理2022年我给一个变电站巡检项目做方案客户要求把指针表读数自动录入后台。手动翻了几十张现场图后我意识到这事并不是简单套一个目标检测就能收尾的巡检机器人拍到的表盘有反光、倾斜、遮挡你得先把“哪个区域是表、哪个不是表”筛出来再把表盘上的指针、刻度从背景里拆出来最后用几何计算算出读数。这个C项目把流水线拆成两段——先用YOLOv5把仪表区域检测出来再用DeepLabV3Plus对表盘做像素级分割最后在C端算指针角度和刻度读数。对做边缘AI盒子、变电站巡检机器人、工厂数字化的工程师来说是一条可以照做并复现的落地路径不是学术demo。2. 数据准备与模型训练YOLOv5检测表盘与DeepLabV3Plus分割指针的选型逻辑2.1 为什么是YOLOv5DeepLabV3Plus这对组合先明确分工。YOLOv5输出的是候选框而不是像素掩码把“整张巡检照片→表盘位置”这件事用几十毫秒做完DeepLabV3Plus接过裁剪后的表盘区域输出逐像素掩码把指针、刻度线、表盘底色分成几类。后面读数的精确度几乎全压在分割质量上。选YOLOv5而不是直接端到端实例分割主要理由是部署成本。YOLOv5在边缘设备上的TensorRT/ONNX Runtime适配非常成熟导出onnx只用一条命令C调用就是常规的session.Run()。表盘检测目标单一、尺度固定不需要P6大模型上场。DeepLabV3Plus看中的是ASPP空洞卷积带来的多尺度感受野对细长指针这类形状比较友好它和YOLOv5一样属于CNN系量化、裁剪、C推理都好上手。换成Transformer类的SegFormer虽然精度可能略好但推理依赖和显存占用在边缘盒子上不划算。也有同行问能不能直接用YOLOv5-seg实例分割一把梭。如果调查数据里表盘互相遮挡严重可以试但分割精度上限比DeepLabV3Plus低。我的倾向还是把检测和分割拆开检测框先做一个硬性的前景筛选后面的分割模型只面对已经裁好的表盘类别分布更干净分割精度更容易调。提示如果现场表盘类型超过20种且指针颜色和表盘背景颜色接近可以考虑把YOLOv5换成YOLOv8-nano。换检测模型只影响检测框质量不影响分割和读数逻辑。2.2 标注体系与数据准备这套方案需要两套标注。第一套是YOLOv5的检测标注用labelImg或者X-AnyLabeling画矩形框只要求把表盘完整框住类别名建议叫gauge。第二套是分割标注推荐labelme输出json再转写成训练所需的mask。分割类别强烈建议设4类background背景、dial_face表盘面、pointer指针、scale_mark刻度线。把刻度线单独分出来后面读数计算会省非常多力气。把刻度线混进表盘面的做法等于给自己埋坑后面做角向定位时还得重新切割。标完的数据要转成YOLO检测格式和DeepLabV3Plus的mask格式。这里贴一个我用惯的转换逻辑labelme的json转换成8位灰度maskimport json import numpy as np import cv2 def labelme_json_to_mask(json_path, out_mask_path, class_id_map): with open(json_path, r) as f: data json.load(f) h, w data[imageHeight], data[imageWidth] mask np.zeros((h, w), dtypenp.uint8) for shape in data[shapes]: label shape[label] if label not in class_id_map: continue pts np.array(shape[points], dtypenp.int32) cv2.fillPoly(mask, [pts], class_id_map[label]) cv2.imwrite(out_mask_path, mask)调用时给class_id_map传入{dial_face: 1, pointer: 2, scale_mark: 3}背景默认0。这段代码解决了两套框架数据集不互通的问题比手工在labelme里切换导出格式可靠得多。数据量方面每个表型至少准备200张现场图。数据来源优先级是巡检视频抽帧 人工拍摄 仿真合成。原理很简单分割模型对光照和视角极其敏感仿真图只能作扩充不能占大头。采集后按8:1:1划分训练/验证/测试增强策略包括随机旋转0°到360°指针表盘旋转不改变语义、HSV随机扰动、高斯噪声、局部块状模糊模拟玻璃反光。反光这个增强项别省现场玻璃表蒙的反光会让分割掩码出现大块空洞后面找指针和刻度的算法直接失效。标注内容工具输出训练输入建议数量表盘检测框YOLO txtyolov5 dataset每型≥500张表盘/指针/刻度掩码labelme jsondeeplabv3plus mask每型≥300张2.3 yolov5训练自己的数据集的超参数怎么调训练命令本身不复杂把数据路径写进mydata.yaml指定检测类别数量即可python train.py --img 640 --batch 16 --epochs 300 \ --data mydata.yaml --weights yolov5s.pt --device 0常见翻车点通常不在命令上而在超参数。我一般固定三个参数img设640低于640会让小型表盘漏检batch在单卡12GB显存下取16再大容易让BN统计量抖动epochs设300但配一个patience30的早停避免过拟合后还在原地打转。YOLOv5默认anchor对表盘这种近似正方形的目标够用如果召回不稳定再去跑anchor聚类不要手工拍脑袋改。DeepLabV3Plus的训练要重点应付类别不平衡。一个画面里背景和表盘面占了九成像素指针和刻度线是少数类。常见做法是给交叉熵loss加类别权重我用的是background: dial_face: pointer: scale_mark 0.2: 1.0: 2.0: 1.5。指针权重最高因为它是读数误差的主要来源。输入尺寸640x640backbone用resnet101batch_size8初始学习率0.01poly策略衰减。loss不降时第一件事检查mask是不是RGB索引映射错了不是换模型。顺便说一下为什么选DeepLabV3Plus而不是U-Net。U-Net在医学图像分割上经典但对细长、跨区域目标指针跨过刻度、被挡板遮住的连续性保持得不如DeepLabV3Plus的ASPP。DeepLabV3Plus的decode head又轻又干净输出边缘比一堆转置卷积叠出来的结果细腻。C部署时这个差异很直观——掩码孔洞少找指针角度不容易跑到边缘锯齿上。3. 模型导出与C推理部署从pt权重到ONNX Runtime的最小可跑链路3.1 导出ONNXyolov5的导出参数与DeepLabV3Plus的动态维度设置两边导出时都要注意动态batch。YOLOv5官方仓库自带export.py直接跑python export.py --weights runs/train/exp/weights/best.pt \ --img 640 --include onnx --dynamic不要在这条命令后面加--end2end。加了之后ONNX里会内嵌一个NMS自定义算子C端要么自己补op实现要么依赖onnxruntime自带的自定义op库在边缘盒子上经常碰到版本不匹配。最省事的是导出纯推理图C端自己写NMS逻辑清晰还方便调阈值。DeepLabV3Plus导出没有官方一键脚本常见做法是复用训练时的forward函数手动走一遍torch.onnx.export# 导出 deeplabv3plus 为 onnx输入 640x640 RGB import torch from model import DeepLabV3Plus model DeepLabV3Plus(num_classes4, backboneresnet101) model.load_state_dict(torch.load(deeplab_best.pth, map_locationcpu)) model.eval() dummy torch.zeros(1, 3, 640, 640) torch.onnx.export( model, dummy, deeplab.onnx, input_names[input], output_names[mask], dynamic_axes{input: {0: batch}, mask: {0: batch}}, opset_version12 )opset_version设12以上对ONNX Runtime 1.14及以后的版本兼容性更好。导出完成后在Python端用onnxruntime直接跑一遍对比原模型输出这一步跳过的话到了C端才发现前处理通道顺序不对排查起来非常费时间。3.2 C环境配置与依赖清单C侧用CMake vcpkg管理依赖是比较省心的组合。ONNX Runtime安装推荐用vcpkgvcpkg install onnxruntime-gpu opencv4如果生产环境要上TensorRT那是另一条路先trtexec把onnx转engine再改推理代码。这里先把ONNX Runtime这条路跑通。CMakeLists.txt的核心查找逻辑cmake_minimum_required(VERSION 3.16) project(gauge_reader LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) find_package(OpenCV REQUIRED) find_package(onnxruntime REQUIRED) add_executable(run_reader src/main.cpp) target_link_libraries(run_reader PRIVATE ${OpenCV_LIBS} onnxruntime::onnxruntime)如果你用的是onnxruntime官方预编译包而不是vcpkg直接把include和lib路径写进target_include_directories也行。两种情况二选一不要混用。常犯的错误是把预编译包和vcpkg版本同时链接进一个工程符号冲突会在运行期以各种奇怪的崩溃方式暴露出来。推理代码里有两个容易忽略的成员变量模型路径的编码。Windows下用宽字符Lyolov5.onnxLinux下没有这个问题。ONNX Runtime的Ort::Session构造函数接受std::wstring或std::string我建议统一封装一个loadModel(const std::string onnxPath)在函数内部按平台转宽字符避免跨平台编译报错。3.3 预处理、推理与后处理letterbox、normalize和NMS下面是能直接照抄的C核心链路。先做letterbox预处理// 将任意尺寸的BGR图统一成640x640记录缩放系数和补边偏移 void letterbox(const cv::Mat src, cv::Mat dst, float scale, int pad_x, int pad_y) { scale std::min(640.0f / src.cols, 640.0f / src.rows); int new_w static_castint(src.cols * scale); int new_h static_castint(src.rows * scale); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); dst cv::Mat(640, 640, CV_8UC3, cv::Scalar(114, 114, 114)); pad_x (640 - new_w) / 2; pad_y (640 - new_h) / 2; resized.copyTo(dst(cv::Rect(pad_x, pad_y, new_w, new_h))); }把BGR转成模型要的NCHW float输入用OpenCV的blobFromImage一行完成cv::Mat blob cv::dnn::blobFromImage(letterboxed, 1.0 / 255.0, cv::Size(640, 640), cv::Scalar(0, 0, 0), true, false);最后一个参数false表示不做均值消减YOLOv5推理时只需做除以255的归一化。这一步之后blob的shape是1x3x640x640通道排布为RGB和训练时保持一致。用cv::dnn的blobFromImage而不是自己写循环原因不只是省代码更重要的是它内部做了正确的内存规划规避了手写NCHW时的stride错误。YOLOv5输出是1x25200x85的张量前四个是cx、cy、w、h接着是objectness后面是类别分数。因为目标只有gauge一类类别分数直接用索引5多类别要遍历6到85。解码函数struct DetBox { float x1, y1, x2, y2, score; }; std::vectorDetBox decodeYolov5(const float* raw, float conf_thresh, int img_w, int img_h, float scale, int pad_x, int pad_y) { std::vectorDetBox boxes; const int anchors 25200, attrs 85; for (int i 0; i anchors; i) { const float* row raw i * attrs; float obj row[4]; if (obj conf_thresh) continue; float cls row[5]; float score obj * cls; if (score conf_thresh) continue; float cx row[0], cy row[1], w row[2], h row[3]; // 先还原到letterbox图的坐标再映射回原图 float x1 (cx - w / 2 - pad_x) / scale; float y1 (cy - h / 2 - pad_y) / scale; float x2 (cx w / 2 - pad_x) / scale; float y2 (cy h / 2 - pad_y) / scale; boxes.push_back({x1, y1, x2, y2, score}); } return nms(boxes, 0.45f); }两个关键参数的取值conf_thresh设0.25太低会放进大量背景框IOU阈值0.45若现场表盘互相遮挡严重可提到0.5默认0.45在稀疏场景下最稳。坐标还原是这里最容易出错的地方漏了pad_x/pad_y检测框整体往左上偏移几十像素裁剪出来的区域会把指针切掉一部分后续分割直接残废。从调试经验看这一步的错误占了C端移植问题的一半。4. 从分割掩码到读数指针角度、刻度定位和量程映射4.1 指针角度提取minAreaRect与PCA两条路径拿到DeepLabV3Plus分割掩码后第一步从mask里抽出pointer类别的2值图。常见做法是先做形态学开运算去掉指针尖上的孤立白点再用minAreaRect求最小外接矩形长边方向就是指针方向float angleFromMask(cv::Mat pointerMask) { // 形态学去噪防止针尖孤立点影响外接矩形 cv::Mat kernel cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(3, 3)); cv::morphologyEx(pointerMask, pointerMask, cv::MORPH_OPEN, kernel); std::vectorstd::vectorcv::Point contours; cv::findContours(pointerMask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); if (contours.empty()) return -1; cv::RotatedRect rect cv::minAreaRect(contours[0]); float angle rect.angle; // opencv返回[-90,0] if (rect.size.width rect.size.height) angle 90.0f angle; // 长轴在垂直方向时的补角 else angle angle 90.0f; return angle; // 归一到[0,180]的线方向 }minAreaRect的问题在于它只反映外接矩形的主轴。如果指针和表盘上的其他细长物体粘连轮廓合并后矩形方向就错了。更稳的方案是对mask里所有像素做PCA主成分分析取第一主成分方向作为指针方向。PCA对细长目标更鲁棒但单帧计算量比minAreaRect大一些。我平时优先用minAreaRect它已经够快一旦发现读数在某个固定表型上反复多算3°到5°再切PCA。4.2 刻度定位与量程映射表盘面上scale_mark的mask是许多短条。用一个以表盘圆心为中心的环形带截取圆的半径取表盘面轮廓最小外接圆半径的0.75到0.95只有落在环内的像素才有可能是刻度线void collectScaleMarks(const cv::Mat markMask, cv::Point center, int r_in, int r_out, std::vectorcv::Point markPoints) { for (int y 0; y markMask.rows; y) { for (int x 0; x markMask.cols; x) { if (markMask.atuchar(y, x) 0) continue; int dx x - center.x, dy y - center.y; int r2 dx*dx dy*dy; if (r2 r_in*r_in r2 r_out*r_out) markPoints.emplace_back(x, y); } } }把收集到的点用连通域分析聚成若干团每团取质心按质心到圆心的角度排序得到一组“角度→刻度编号”的映射表。如果表盘上有10根主刻度就得到10个角度。主刻度之间常穿插短子刻度子刻度的数量用于细分插值。没有子刻度的表盘主刻度的角度差本身就已经承载了量程的线性分布不需要额外识别表盘上的数字。这里有个落地技巧如果刻度线轮廓有大有小先按轮廓长度排序取最长的那批做主刻度候选这批通常对应数字较大的主刻度点。4.3 角度转读数跨零度是唯一容易写错的地方读数计算不能只靠“指针角度/360×量程”。真实表盘的刻度范围不是整圆0°可能刻在左下方300°刻在右下方中间缺角。必须用“刻度角度区间线性插值”struct ScaleMark { float angle; float value; }; float readByScale(const std::vectorScaleMark marks, float pointerAngle) { int n marks.size(); for (int i 0; i n; i) { int j (i 1) % n; float a0 marks[i].angle, a1 marks[j].angle; float v0 marks[i].value, v1 marks[j].value; if (a1 a0) a1 360.0f; // 跨过0度 float pa pointerAngle; if (pa a0) pa 360.0f; if (pa a0 pa a1) { float t (pa - a0) / (a1 - a0); return v0 t * (v1 - v0); } } return -1.0f; // 指针没有落在标定区间 }指针位于0度附近时上面的循环会把pa强制加360确保落在最后一个区间里。不提前做跨零修正指针读数会在每次经过0度交面时瞬间从最大值跳回最小值。这段代码只有十几行但很多人在跨零修正上吃过亏。实际项目中刻度值的初始化常用一个显式标定文件{ minValue: 0.0, maxValue: 0.6, numMajorMarks: 11, zeroAngleDeg: -45.0, maxAngleDeg: 225.0 }读取json后每个主刻度角度zeroAngleDeg i×(maxAngleDeg - zeroAngleDeg)/(numMajorMarks-1)再和4.2实测的刻度角度做对齐。两套角度如果偏差超过3°多半是圆心标定不准确重新取表盘面mask的质心即可。5. C仪表读数项目的五个典型坑现象、原因与解决顺序5.1 ONNX导出后检测框整体偏移现象同一张测试图在Python端用torch推理完全正常换成C加载onnx后检测框整体向左上偏移20到40像素。原因YOLOv5导出的是纯推理图它输出的cx/cy/w/h是letterbox坐标系下的值。C端忘了记录pad_x/pad_y和缩放scale或者记录后没在解码时减回去目标框带着补边偏移一起被画回原图。解决在预处理函数里把scale、pad_x、pad_y存成成员变量解码时统一还原顺序是先减pad再除以scale。5.2 分割掩码把指针和表盘边缘混淆现象白底表盘深色指针大部分图正常换成浅色指针配深色表盘时分割结果反转指针被标成背景。原因pointer类别在整张图里像素占比不到1%交叉熵loss天然偏向背景。即使加了权重如果权重只在训练尾部生效分割头对少类的判别力仍然不够。解决把pointer类别权重提到2.0到3.0或者改成两阶段处理——先分割整个表盘区域再在表盘区域内单独训一个指针分割模型。数据量不足时更直接的做法是每个batch里保证至少30%样本是指针完整的强样本。5.3 玻璃反光导致掩码空洞现象表盘上方有一片白色高光高光区域内的刻度线和指针全被识别为背景掩码出现大块空洞。原因训练集里玻璃反光样本太少。反光不是简单亮度变化而是局部对比度骤降还混入环境光颜色。解决在数据增强里增加“反光模拟”——在表盘区域随机位置叠一个半透明白色椭圆透明度0.6到0.9位置多次变化。推理阶段我坚持不做额外去反光预处理因为边缘盒子上没有实时去反光算法加了会拖慢速度宁可把增强样本放进训练让模型自己学会忽略高光。5.4 onnxruntime库与C编译链不匹配现象CMake链接通过运行到打开模型时报OnnxRuntime版本与opset不兼容或报缺少libonnxruntime_providers_cuda.so。原因onnxruntime预编译包分CPU和GPU版本还区分gcc/msvc工具链。如果vcpkg安装的是onnxruntime-gpu而项目实际在纯CPU工控机上跑库会主动加载CUDA provider然后失败。解决一个工程只保留一个provider。没有GPU的工控机直接vcpkg install onnxruntime不要装-gpu后缀。升级到onnxruntime 1.16.3以上后opset12的模型依然兼容不要为了“升级”把能跑的链路重导一遍。5.5 指针读数跨零度跳变现象指针停在接近0刻度的位置时读数在最小值附近和满量程附近反复横跳。日志显示角度本身只在小幅浮动8°到12°但映射出来的读数异常大。原因角度归一化顺序错了。有人先把指针角度强制转换到[0,360)再和刻度区间比较指针实际位于0度附近时区间运算被算出负数。解决比较前把指针角度映射到和刻度区间同一参考系先比较再归位。上面readByScale里的if (pa a0) pa 360就是标准做法。换坐标系还在跳的话把minAreaRect得到的线方向只有[0,180]补齐成[0,360]的指针方向——用表盘圆心和指针尖的坐标差判定指针在哪个半轴。6. 把读数误差压到±1%以内量程自标定与验证方式6.1 三点量程自标定模型输出是相对可靠的角度但表盘安装误差、拍照角度、圆心标定误差都会带来系统偏差。常见做法是“三点自标定”首次部署或换表型时把指针拨到三个已知读数位置0%、50%、100%量程各跑一次模型记录三个指针角度用三段线性映射修正原始角度struct CalibMapping { std::vectorfloat knownAngles; // 0%, 50%, 100% 对应的角度 std::vectorfloat knownValues; // 0, 0.3, 0.6 (以0.6MPa表为例) }; float calibrateAndRead(const CalibMapping cal, float rawAngle) { if (rawAngle cal.knownAngles[1]) return cal.knownValues[0] (rawAngle - cal.knownAngles[0]) / (cal.knownAngles[1] - cal.knownAngles[0]) * (cal.knownValues[1] - cal.knownValues[0]); return cal.knownValues[1] (rawAngle - cal.knownAngles[1]) / (cal.knownAngles[2] - cal.knownAngles[1]) * (cal.knownValues[2] - cal.knownValues[1]); }三个点而不是两个点是为了吸收表盘圆心标定带来的非线性偏差。只标首尾两个点相机偏离正视角时转角误差会被直接放大成线性读数误差。6.2 用200张真值图压出P95误差部署前验证不能只看几张图。建议每个表型准备200张带真值的截图人工从标准表源标好实际读数跑完整流水线统计绝对误差表型测试数平均绝对误差P95绝对误差0.6MPa压力表2000.004MPa0.008MPa0~100A电流表2000.6A1.2A判断标准是最大量程的1%以内可接受P95超过2%就回头查第5章。误差主要来源是分割mask质心的离散度和角度拟合的残差P95指标能真实暴露跨零度和反光这类边界问题。回到第一个变电站项目这套表盘方案跑完之前人工巡检读表要30秒现在五帧取均值后输出读数不到80毫秒。项目推进中我反复提醒自己一句话模型精度不是最难啃的部分难的是让C部署链路对坐标、类别和角度标准从头到尾保持一致。希望这套拆解对你有帮助。本文还有配套的精品资源点击获取