
简介这是一份面向C# WinForms开发者的工业视觉检测Demo源码演示如何通过YOLOv8 ONNX模型完成PCB板缺陷检测missing_hole。程序支持Baumer工业相机SDK采集图像也兼容本地图片输入适合需要快速搭建视觉检测原型、学习YOLO模型集成或替换为Basler、Daheng等其他相机接口的工程师。资源共141个文件压缩包约65MB主要包含C#源码15个cs、运行时依赖48个dll、项目配置sln/csproj、settings、config以及模型文件onnx等dll用于支撑相机SDK与ONNX推理配置和图片资源方便调试。已有182人学习下载。通过这份源码可直接运行Demo观察工业相机取流、YOLOv8推理、WinForms实时画框显示置信度的完整流程代码结构简洁关键模块易于定位便于二次开发与算法替换。1. 为什么我用C# WinForms YOLOv8做PCB缺陷检测现场设备给的上位机答案工业现场的PCB缺陷检测算法跑通只算完成一半剩下一半是把模型塞进产线上真正自己会动的上位机程序里。这套源码做了一件很务实的事用C# WinForms把YOLOv8模型、工业相机实时采集和本地图片推理串成一条完整链路检测项覆盖缺件、偏移、焊点桥连这类常见缺陷模型统一走ONNX Runtime工控机上不需要额外装Python环境。它适合两类人一类是手里有Basler这类工业相机、正在写产线上位机的工程师另一类是刚用YOLOv8训练完PCB缺陷数据集、想要一个能直接对接工业场景的C#推理壳子的开发者。整体从图像进来到检测框画出来都是可视化操作调试直观换自己的模型权重也方便。2. 先定引擎再写代码为什么YOLOv8推理我选ONNX Runtime2.1 绕过Python环境三种C#推理路径的取舍YOLOv8训练时用的是PyTorch但把PyTorch直接搬进C#上位机是我最早尝试过、后来彻底放弃的一条路。TorchSharp目前的API对YOLO这类带动态shape和自定义后处理的模型兼容性不够部署一个模型要处理一堆算子映射而且版本更新频繁今天能跑的代码下周可能就废了。ML.NET我也看过它对ONNX模型的支持主要面向分类和目标检测的标准输出YOLOv8这种输出需要自己写解析的模型ML.NET给的抽象反而碍手碍脚。绕了一圈最后稳定下来的是ONNX Runtime理由很实际路径环境依赖模型兼容性部署体积实际体验TorchSharp需PyTorch原生库YOLO算子映射麻烦大适合研究不适合产线ML.NET无Python抽象层重自定义后处理难绕过中写YOLO绕远路ONNX Runtime无Python纯C核官方导出直接可跑小当前最省事ONNX Runtime在C#侧就是一个NuGet包发布时把推理DLL一起带出去现场机器不需要装Python、不需要配CUDA也能跑CPU推理。而且后续要上GPU只需要换一个带Gpu后缀的包业务代码一行都不用改。2.2 从.pt导出ONNXUbuntu 20.04 CPU环境下的导出命令与参数YOLOv8官方的ultralytics仓库自带导出功能在训练机上装好环境一条命令就能完成导出yolo export modelweights/best.pt formatonnx imgsz640 opset12 simplifyTrue几个参数的具体取法说一下。imgsz640是YOLOv8默认输入尺寸不要为了追求速度改成416或320PCB板上的焊点缺陷尺寸很小分辨率一降召回率立刻崩。opset12是ONNX Runtime在工控机上兼容性最好的版本不需要追高。simplifyTrue会调用onnxsim把计算图的冗余节点合并掉模型体积能小10%~20%推理也能快一点。这里有个真实翻车案例Ubuntu 20.04上如果numpy版本太新导出时会报AttributeError: module numpy has no attribute int。原因是numpy从1.24开始移除了np.int这类别名。解决很简单把numpy降到1.23.x再导出pip install numpy1.24导出完别急着往C#里搬先做一个对比验证确认ONNX输出和PyTorch原模型一致from ultralytics import YOLO model_pt YOLO(weights/best.pt) model_onnx YOLO(weights/best.onnx) res_pt model_pt.predict(val.jpg, imgsz640, conf0.25) res_onnx model_onnx.predict(val.jpg, imgsz640, conf0.25)两组结果逐框对比坐标和置信度偏差在3~5个像素以内算正常。这一道验证做在前面能省掉后面C#端排查的很多时间。2.3 C#侧依赖与项目骨架NuGet引用和目录结构C#侧要引的包就三个PackageReference IncludeMicrosoft.ML.OnnxRuntime Version1.16.3 / PackageReference IncludeOpenCvSharp4.Windows Version4.8.0.20230708 / PackageReference IncludeOpenCvSharp4.Extensions Version4.8.0.20230708 /选版本有个坑ONNX Runtime 1.16.x对应CUDA 11.81.15.x对应CUDA 11.8和12.x1.14.x支持CUDA 11.2。如果工控机显卡驱动老只支持CUDA 11.2你装1.16.3的Gpu版会直接启动失败。我一般先把显卡驱动版本查清楚再反推装哪个版本的ONNX Runtime。项目骨架按这个结构拆PcbDefectDetector/ ├── PcbDefectDetector.csproj ├── Models/ │ └── YoloModel.cs # 模型元数据类别名、输入尺寸 ├── Inference/ │ ├── OnnxInference.cs # 推理会话和Detection结果定义 │ ├── Preprocess.cs # LetterBox预处理 │ └── Postprocess.cs # NMS后处理与坐标还原 ├── Cameras/ │ ├── ICamera.cs # 相机抽象接口 │ └── BaslerCamera.cs # Basler相机实现 ├── UI/ │ └── MainForm.cs # WinForms主界面 └── weights/ └── pcb_best.onnx # 训练好的PCB缺陷模型拆这个结构的核心原因是换相机品牌不用动推理代码换模型权重不用动界面代码。Models/YoloModel.cs里定义的类别名要和训练数据集里的data.yaml保持一致顺序错了检测框画出来类别名全是乱的。这个文件虽然小但它是整个项目的语义字典改模型时必须第一个改它。3. 图像从哪来工业相机实时采集与本地图片双通道接入3.1 Basler相机接入pylon回调模式与参数固化工业相机和USB摄像头最大的区别是触发方式、曝光控制和帧缓冲都不在一个层次上。Basler相机用pylon SDK以pylon 7.x的Basler.Pylon命名空间为例接入代码是这样using Basler.Pylon; public class BaslerCamera : IDisposable { private Camera? _camera; public bool Open(int index) { _camera new Camera(index); _camera.Open(); // 关掉触发用连续采集模式上位机只管取最新帧 _camera.Parameters[PLCamera.TriggerMode].SetValue(PLCamera.TriggerMode.Off); // 固定帧率10fps给推理留足处理时间 _camera.Parameters[PLCamera.AcquisitionFrameRateAuto].SetValue(PLCamera.AcquisitionFrameRateAuto.Off); _camera.Parameters[PLCamera.AcquisitionFrameRate].SetValue(10.0); // 固定曝光和增益避免自动调整导致检测率波动 _camera.Parameters[PLCamera.ExposureAuto].SetValue(PLCamera.ExposureAuto.Off); _camera.Parameters[PLCamera.GainAuto].SetValue(PLCamera.GainAuto.Off); _camera.StreamGrabber.ImageGrabbed OnGrab; _camera.StreamGrabber.Start(); return true; } private void OnGrab(object sender, ImageGrabbedEventArgs e) { var result e.GrabResult; if (!result.IsValid) { e.Handled true; return; } using (result) { Bitmap? bmp result.ConvertToBitmap(); ImageGrabbed?.Invoke(this, bmp); } e.Handled true; } public event EventHandlerBitmap?? ImageGrabbed; }e.Handled true这句不能省它告诉pylon这个GrabResult已经处理完可以回收缓冲。ConvertToBitmap是pylon自带转换方法适合低分辨率或低频场景对于500万像素以上的相机后面会说到更快的做法。这里用回调而不是轮询因为pylon的采集线程有自己的缓冲管理回调里拿到的是最新一帧轮询反而会累积延迟。参数固化在产线调试中很重要。自动曝光和自动增益在实验室看着方便产线上不同料板反光不同相机自己把曝光调到飞起检测率会明显波动。我会先手动调一组适合当前工位的曝光和增益然后写死配合固定光源检测结果才有可比性。3.2 本地图片加载统一格式比追求格式多样更重要本地图片通道主要用来做离线验证和产线调试比如现场拍了一张疑难板子想在工控机上复现检测结果。加载本身简单但有一个坑Bitmap的PixelFormat五花八门有8位灰度、24位RGB、32位ARGB而YOLO预处理只认RGB三通道不统一格式必然出错。public static Bitmap LoadLocalImage(string path) { using var original new Bitmap(path); // 不管原图是灰度还是32位ARGB统一转成24位画布 var bmp new Bitmap(original.Width, original.Height, PixelFormat.Format24bppRgb); using (var g Graphics.FromImage(bmp)) { g.DrawImage(original, 0, 0, original.Width, original.Height); } return bmp; }这里有个细节Format24bppRgb虽然名字带Rgb实际内存布局是BGR顺序和YOLOv8训练时用的RGB顺序不同后面预处理里要做一次通道翻转否则检测框能画出来但目标全被判错这是第四章会细讲的坑。另外工业相机的12位或16位原始图像直接new Bitmap(path)打不开需要先用相机SDK或OpenCvSharp按原始数据读入再转8位然后走上面的统一流程。我建议在本地加载通道里留一个OpenCvSharp的Cv2.ImRead分支专门处理16位TIFF这类工业图比去猜Bitmap的像素格式省事。3.3 相机选型与镜头选型对检测结果的隐性影响很多项目把相机选型理解成分辨率够不够但在PCB缺陷检测里真正要看的是分辨率、帧率、传感器尺寸这三个参数联动。检测目标最小特征建议分辨率建议传感器0402贴片缺件0.3mm500万像素以上1/1.8英寸及以上通孔焊点桥连0.2mm500万~1200万2/3英寸金手指划痕0.1mm1200万以上远心方案帧率取决于产线节拍静止拍摄10fps够用如果是流水线运动拍摄按板子移动速度至少要30fps以上。传感器尺寸直接影响镜头选型1/1.8英寸的传感器配8mm镜头能看到的分辨率和1/2.5英寸完全不同。镜头选型更依赖经验。PCB检测里最常踩的坑是景深不够板子有一点翘曲边缘芯片已经跑出焦平面检测框全挤在中间区域。常见做法是把光圈收到F8左右用景深换清晰范围同时配合条形光源做低角度照明让焊点轮廓的反差出来。如果预算允许远心镜头是PCB检测的最优解透视畸变小厚板子边缘和中心放大率一致后期算法不用做畸变校正。4. 把推理管线串起来从Bitmap到检测框的完整链路4.1 预处理LetterBox填充、归一化与Tensor构造YOLOv8的输入是固定640×640正方形图而相机画面是16:9或4:3直接拉伸会破坏宽高比检测小缺陷时偏差被放大。所以预处理必须走LetterBox等比缩放后用灰色填充补到正方形。public static float[] LetterBox(Bitmap src, int inputSize, out float ratio, out int padX, out int padY) { // 计算等比缩放比例取短边方向的最小值 ratio Math.Min((float)inputSize / src.Width, (float)inputSize / src.Height); int newW (int)Math.Round(src.Width * ratio); int newH (int)Math.Round(src.Height * ratio); padX (inputSize - newW) / 2; padY (inputSize - newH) / 2; // 等比缩放原图 using var resized new Bitmap(newW, newH); using (var g Graphics.FromImage(resized)) { g.InterpolationMode InterpolationMode.HighQualityBilinear; g.DrawImage(src, 0, 0, newW, newH); } // 创建640x640画布灰色填充把缩放图居中贴上去 using var canvas new Bitmap(inputSize, inputSize); using (var g Graphics.FromImage(canvas)) { g.Clear(Color.FromArgb(114, 114, 114)); g.DrawImage(resized, padX, padY, newW, newH); } // 逐像素读取BGR转RGB并归一化到[0,1] var data new float[3 * inputSize * inputSize]; var bmpData canvas.LockBits(new Rectangle(0, 0, inputSize, inputSize), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); unsafe { byte* p (byte*)bmpData.Scan0; int stride bmpData.Stride; for (int y 0; y inputSize; y) { for (int x 0; x inputSize; x) { int idx y * stride x * 3; int dstIdx (y * inputSize x) * 3; data[dstIdx] p[idx 2] / 255f; // R通道 data[dstIdx 1] p[idx 1] / 255f; // G通道 data[dstIdx 2] p[idx] / 255f; // B通道 } } } canvas.UnlockBits(bmpData); return data; }这里有三处容易错。第一填充值用灰色114/255和YOLOv8训练时的LetterBox默认值保持一致填255或0都会让边缘检测框置信度异常。第二BGR到RGB的交换在逐像素循环里做不要在存完图后再翻转否则多一次遍历实时推理里这个时间不该花。第三InterpolationMode.HighQualityBilinear对工业缺陷检测足够Lanczos对速度影响明显但精度提升几乎不可见。4.2 推理会话与输入输出拼装固定输入尺寸的细节预处理拿到float数组后组装成Tensor并跑推理。OnnxInference类把会话生命周期和业务解耦using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class OnnxInference : IDisposable { private readonly InferenceSession _session; public OnnxInference(string modelPath) { var opts new SessionOptions(); // CPU线程数先从1开始避免和采集线程抢占 opts.AppendExecutionProvider_CPU(1); _session new InferenceSession(modelPath, opts); } public float[] Run(float[] input, int inputSize) { var dim new[] { 1, 3, inputSize, inputSize }; var tensor new DenseTensorfloat(input, dim); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, tensor) }; using var results _session.Run(inputs); // YOLOv8默认输出名output0形状[1, 4classes, 8400] return results.First().AsTensorfloat().ToArray(); } }AppendExecutionProvider_CPU(1)里的1是线程数不是0。给1是保守做法后面推理耗时太长可以加到2或3但不要超过CPU物理核数否则工控机上其他线程全饿死。输入名images不是随便写的是导出ONNX模型时输入节点的名字如果导出时改过名这里要严格对应否则直接抛异常。输出端有个隐藏点AsTensorfloat().ToArray()会做一次完整拷贝5类输出数组大小是9×840075600个float拷贝开销可以忽略。真正吃时间的还是NMS因为8400个候选框全部要遍历一遍。4.3 NMS后处理与坐标还原把输出拉回原图坐标系YOLOv8的输出布局是[1, 4classes, 8400]每个候选框的属性按类目分通道排布。这里是最容易写出检测框全对但坐标全乱的地方public static ListDetection PostProcess(float[] output, int numClasses, float confThreshold, float nmsThreshold, float ratio, int padX, int padY) { int numBoxes 8400; var candidates new ListDetection(); for (int i 0; i numBoxes; i) { // 找当前框里置信度最高的类别 float bestScore 0f; int bestClass -1; for (int c 0; c numClasses; c) { // 列优先索引第c类对应的得分排在 (4c)*8400 i float score output[(4 c) * numBoxes i]; if (score bestScore) { bestScore score; bestClass c; } } if (bestScore confThreshold || bestClass 0) continue; // 读取归一化坐标 float cx output[i]; float cy output[numBoxes i]; float w output[2 * numBoxes i]; float h output[3 * numBoxes i]; // 关键先减pad再除ratio顺序不能反 float x1 (cx - w / 2f - padX) / ratio; float y1 (cy - h / 2f - padY) / ratio; float x2 (cx w / 2f - padX) / ratio; float y2 (cy h / 2f - padY) / ratio; candidates.Add(new Detection { X1 x1, Y1 y1, X2 x2, Y2 y2, Score bestScore, ClassId bestClass }); } return NonMaxSuppression(candidates, nmsThreshold); }索引公式output[(4 c) * numBoxes i]是整个后处理里最重要的地方。因为C#拿到的数组是行连续存储而ONNX输出张量可能是[1, 8400, 4c]或[1, 4c, 8400]两种布局。上面这段按[1, 4c, 8400]写如果你导出时做过转置布局变成[1, 8400, 4c]索引要改成output[i * (4 numClasses) 4 c]。判断方法很简单先打印输出数组长度是(4类数)*8400就是前者是8400*(4类数)就是后者。NMS部分用列表排序加IoU过滤private static ListDetection NonMaxSuppression(ListDetection dets, float nmsThresh) { var result new ListDetection(); // 按置信度从高到低排序 dets.Sort((a, b) b.Score.CompareTo(a.Score)); while (dets.Count 0) { var best dets[0]; result.Add(best); dets.RemoveAt(0); // 同类别且IoU超过阈值则抑制 dets.RemoveAll(d d.ClassId best.ClassId IoU(best, d) nmsThresh); } return result; }这个NMS写法不是性能最优的但对PCB缺陷检测完全够用。工业场景里每张板子真缺陷数量一般不超过20个候选框虽然多大部分在置信度过滤阶段已被去掉。4.4 UI线程刷新与检测框绘制别在Grab回调里画界面推理和画图必须分线程否则相机回调会把UI线程堵死。我的固定做法是采集回调里只做取帧和触发推理耗时代码放在Task.Run里推理完成后再用BeginInvoke回UI线程画框private void GrabHandler(object? sender, Bitmap? bmp) { if (bmp null) return; // 拷贝一份避免相机缓冲被释放后Bitmap失效 var clone (Bitmap)bmp.Clone(); bmp?.Dispose(); Task.Run(() { var sw Stopwatch.StartNew(); var dets _yolo.Detect(clone, 0.25f, 0.45f); sw.Stop(); if (IsHandleCreated) { BeginInvoke(new Action(() { using var g Graphics.FromImage(pictureBox.Image); foreach (var d in dets) { var rect Rectangle.Round(new RectangleF( d.X1, d.Y1, d.X2 - d.X1, d.Y2 - d.Y1)); g.DrawRectangle(Pens.Lime, rect); g.DrawString(${_labels[d.ClassId]} {d.Score:P0} {sw.ElapsedMilliseconds}ms, _font, Brushes.Lime, rect.X, rect.Y - 18); } pictureBox.Invalidate(); })); } clone.Dispose(); }); }两个容易忽视的点。bmp.Clone()必须做因为GrabResult在离开回调时立即回收缓冲原Bitmap变成野指针不Clone的话画图时会随机花屏。另外推理耗时直接打在图上现场调试时很方便产线工程师看一眼就知道当前帧率余量够不够。5. 调试中踩过的坑五条影响出图的典型问题和排查记录5.1 现象CPU版推理速度慢到没法看一张640×640的YOLOv8s模型在普通工控机上推理耗时跑到800ms以上产线连每秒一帧都达不到。原因工控机没装GPU版ONNX Runtime或者装了GPU包但CUDA版本对不上运行时回退到CPU执行。解决先看NuGet包引用的是Microsoft.ML.OnnxRuntime还是Microsoft.ML.OnnxRuntime.Gpu前者就是纯CPU推理。换GPU包后ONNX Runtime 1.16.x对应CUDA 11.8机器装的是CUDA 11.2或12.0时运行时会直接抛DLL加载失败。这种问题在NuGet包发布说明里有CUDA对照表换包前先查显卡驱动支持的CUDA版本。一个快速验证方法程序启动时加一行日志ONNX Runtime会打印当前用的是CPU还是CUDA执行器不用靠猜。5.2 现象检测框偏移或者整体偏小模型跑通了框也能画出来但框的位置明显偏左上或右下有的框尺寸比实际焊点小一圈。原因坐标反算时把padX/padY和ratio带错了或者把运算顺序写反。常见错误写法是float x1 (cx - w / 2f) / ratio - padX;正确写法是float x1 (cx - w / 2f - padX) / ratio;。填充是用在缩放后的坐标系里的必须先扣除pad再除ratio。另一种情况是预处理前图片已经被改过尺寸ratio和实际缩放不符。解决把ratio、padX、padY这三个值从预处理函数一路传出来不要在后处理里重新计算。我习惯在Detection类里加一个SourceRect字段把原始Bitmap尺寸也带进去调试时直接打印原始分辨率/缩放比/填充值一眼就能看出坐标还原哪一步错了。5.3 现象相机画面偶发花屏或丢帧运行一段时间后画面突然出现横条纹花屏或者帧率从10fps掉到2fps重启程序恢复正常但过一会又犯。原因GrabResult的Bitmap转换发生在采集回调里占用了pylon内部缓冲下游处理慢时缓冲溢出。另外e.Handled true漏设会让缓冲不被释放时间长了内存和句柄都涨上去。解决回调里不要做高开销操作。把ConvertToBitmap替换成直接读指针拷贝// pylon 7.x中获取原始数据指针并拷贝到预分配buffer IntPtr ptr grabResult.GetPointer(); int len grabResult.PayloadSize; Marshal.Copy(ptr, _buffer, 0, len);这样回调只做一次内存拷贝500万像素图耗时约0.2ms推理完全放到Task.Run里。如果这样还丢帧就把相机帧率降到5fps优先保住不丢帧产线检测只看最稳定的一帧就够。5.4 现象多相机内存持续上涨最终卡死接了2~3个相机后任务管理器里内存曲线一路爬升一天能涨几百MB。原因每个GrabResult转换出来的Bitmap没有及时Dispose或者Task.Run里的闭包持有Bitmap引用导致GC一直无法回收。解决Bitmap统一走using或try-finally释放Task.Run内部不要再对原始Bitmap做引用。多个相机共用一个推理引擎实例没问题但每个相机的采集缓冲要独立不要共用一个byte[]否则图像数据互相覆盖检测结果会串。5.5 现象检测率从90%掉到60%但模型没换过同一套模型上午检测率90%下午掉到60%现场说什么都没动过。原因生产环境光照在变化或者相机的自动曝光、自动白平衡被误打开图像整体过曝或偏色。也有可能是镜头被擦了以后焦点偏移。解决先把相机的ExposureAuto、GainAuto、WhiteBalanceAuto全部关掉固定参数。然后在代码里加一个简单的亮度检查var bmpData bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); byte* p (byte*)bmpData.Scan0; long sum 0; int count bmp.Width * bmp.Height; for (int i 0; i count; i) sum p[i * 3]; // 灰度图下三通道近似相等取B通道即可 bmp.UnlockBits(bmpData); float mean sum / (float)count; if (mean 20 || mean 200) // 过暗或过曝的简单判定 lblBrightness.Text $亮度异常: {mean:F0};从那以后我部署完每个项目都会在界面上放一个实时灰度直方图现场维护人员看到直方图形状不对就知道去查光源不用每天跟在产线后面解释。6. 让检测系统更稳的几步从单相机到多工位6.1 多相机并行推理的线程模型多相机场景和单相机有本质区别每个相机都有独立采集通道如果排队共用同一个推理会话高速相机就会被相机1的推理耗时拖住。常见做法是给每个相机分配独立的InferenceSession实例同时用SemaphoreSlim限制并发数避免推理请求同时涌入把CPU打爆public class MultiCameraPipeline { private readonly SemaphoreSlim _gate new(2); private readonly OnnxInference _inference new(pcb_best.onnx); public async TaskDetection[] DetectAsync(Bitmap frame) { await _gate.WaitAsync(); try { return await Task.Run(() _inference.Detect(frame)); } finally { _gate.Release(); } } }SemaphoreSlim(2)的2要根据CPU核数定。我见过一个项目在4核工控机上把并发数设为4结果推理没变快UI线程反而卡到拖不动。原因是ONNX Runtime内部有自己的线程池外层再开几个并发线程切换开销全耗在CPU争抢上。一般来说物理核数的一半是一个安全的起点。6.2 用标定图片验证检测精度模型部署到现场后不要只看一两张图觉得挺准就完事。我一般会在现场保存20张典型PCB图覆盖缺件、偏移、桥连、正常板各5张部署时全部跑一遍统计检出率和误检率。做法是维护一个CSV记录每行包含文件名、缺陷类别、置信度、框坐标。这批标定图就是现场的金标本以后升级模型或改参数都拿这组数据回归一遍对比标准是IoU大于0.5且类别一致算命中正常板误报一个都算不过关。6.3 把检测结果落到本地日志产线运行中需要留痕做质量追溯。我用最简单的方式实现每次检测只追加一行CSV写入本地文件。注意写入时要做缓冲不要每次检测都打开关闭文件高频下会拖垮IO。更好的做法是先攒20条批量写一次这样即使程序崩溃最多丢20条记录对产线追溯来说可以接受。从那以后我部署任何检测项目都会先确认日志模块已经提前就位这个比模型参数还重要——没有日志出了问题就只能靠现场工程师的玄学排查了。希望帮到你。本文还有配套的精品资源点击获取