首页 / 资讯中心 / 文章详情

C#机器视觉数字识别产线稳定化:从取图预处理到拒识与并发

C#机器视觉数字识别产线稳定化:从取图预处理到拒识与并发 ★ FEATURED ARTICLE
C# 视觉检测里的数字识别坑基本都不在识别这一步。我见过太多工位算法选型讨论了两周代码写完跑单张图全对一上线连续跑三个小时就开始零星报错拆开看是取图那一步字节对齐出了问题或者某个Mat忘了释放把内存吃满了。数字识别这条链路从相机出图、内存排布、二值化、切分、归一化到最终推理任何一环偷懒最后都会以识别不准的形式暴露在操作员面前然后所有人回过头去怀疑模型。这篇内容想讲清楚一件事在 C# 环境下做一个能稳定跑在产线上的数字识别工位需要哪些环节配合每个环节的关键参数为什么这么定以及哪些地方是踩过一次就再也不想踩第二次的。适合已经能写 OpenCvSharp 基本操作、准备把 Demo 变成正式工位的开发者也适合负责验收、想知道该盯哪些指标的人。代码片段以 OpenCvSharp4、Tesseract 5、ONNX Runtime 为主思路换到别的库同样成立。1. 数字识别工位的翻车点往往在你写第一行代码之前就埋下了1.1 三类反复出现的现场故障指向同一个根因第一类是前半小时全对后面开始飘。操作员反馈得很形象刚开机好好的越跑越差。拆开看通常是光源发热导致亮度漂移或者镜头上有粉尘积累阈值是开机时手动拖出来的一个固定值环境一变就整体失效。这类问题的根因不是算法弱而是整条链路里没有任何一个环节在监控图像质量本身。第二类是换一批料就崩。同一种零件供应商换了一批油墨或者表面处理方式从喷砂换成了拉丝字符和背景的对比度分布完全变了。固定阈值在这里必然失效。第三类更隐蔽单张测试一切正常连续跑八小时以后进程内存涨到两三个 G最终在某次 GC 之后出现偶发的图像撕裂或者直接抛异常。这类去看代码十有八九是Mat对象在循环里创建了没释放或者Bitmap的LockBits之后在某条异常分支上没有UnlockBits。这三类问题的共同点在于它们都不属于识别算法的范畴但都会以识别错误的形式呈现。所以真要做这个工位第一步不是挑模型而是把取图、监控、资源管理这三件事做扎实。1.2 把识别正确定义清楚比选算法重要得多这件事上我吃过亏。早期做的一个工位需求方说要能读出来我就按单字符准确率去调调到 99.5% 觉得挺美。上线之后对方说不行因为他们的业务口径是整串必须全对一串 12 位编号里错一位这条记录就废了。单字符 99.5% 在 12 位串上折算下来整串正确率只有 94% 左右按每天几万件的量算返工量相当可观。所以动手之前必须和需求方对齐四个数字整串识别正确率的下限、允许的误识率把 A 读成 B、允许的漏识率读不出来报错、以及单件处理的时间预算。这四个指标之间是互相拉扯的把误识率压到极低通常意味着拒识率会上升把节拍压到 20 毫秒通常意味着没法做太复杂的预处理。不做这个取舍后面所有调参都是盲调。还有个容易被忽略的点字符集到底有多大。如果只是固定的一串流水号字符集是 0-9 加可能的分隔符那问题规模很小如果还要处理字母、汉字、符号混排那就完全是另一个量级的工作。我在项目里会在配置里显式声明字符集识别环节只在这个集合里出结果任何落在这个集合外的输出直接判为拒识这一条能砍掉相当一部分离谱误判。1.3 验收样本集的构造方式样本集的构造直接决定了你调的参数是不是在自欺欺人。常见的错误做法是从一批好料里随机抽两百张图调到全对就交付。正确的做法是刻意去凑难样本光照最暗的、最亮的、字符磨损最严重的、有油污遮挡的、位置偏得最厉害的、背景反光最强烈的每类凑十几到几十张单独组一个集合每次改完参数先在这个集合上跑一遍。我自己的习惯是把样本分成三份训练/调参用的常规集、专门挑刺的困难集、以及完全没参与调参的留出集。前两份自己用第三份只在准备交付时跑一次。如果留出集的表现明显差于困难集说明参数过拟合到了自己挑的那些样本上需要回头补样本多样性。这个流程听起来繁琐但比上线之后被现场追着改要省事得多。2. 取图与预处理精度是在内存里丢掉的不是在算法里丢掉的2.1 像素格式、步长与锁位图里的三个隐雷第一个雷是步长。相机 SDK 交出来的缓冲区每行字节数通常会被对齐到 4 的倍数也就是所谓的 stride 或者 step。假设图像宽 641 像素、单通道那么每行实际占 644 字节而不是 641。如果你直接new Mat(rows, cols, MatType.CV_8UC1, ptr)而不带 step 参数OpenCV 会按 641 去解析图像就会呈现斜向的错位条纹。这个现象很有辨识度一旦看到画面像被斜着切了一刀先查 step。// 正确做法显式传入每行字节数 var mat new Mat(height, width, MatType.CV_8UC1, ptr, stride);第二个雷是灰度图的调色板。有些相机输出Format8bppIndexedBitmap对象本身带一个 256 项的调色板。如果你只是把数据拷出来当灰度用数值是对的但如果中间经过了某些绘图 API 或者保存成文件颜色会完全错乱——因为调色板没跟着走。用BitmapConverter.ToMat转换时会处理这件事但如果你自己走LockBits零拷贝的路线就要清楚自己在做什么。第三个雷是彩色转灰度的时机。有些方案为了省事全程用 BGR 三通道跑最后识别前才转灰度。这在算力上浪费很大三通道的内存带宽是三倍。更好的做法是在取图后立刻转单通道后续所有处理都在单通道上完成。转换方式上Cv2.CvtColor用的加权系数是 0.299/0.587/0.114如果字符是红色的、背景是深色的按这个权重红色分量贡献很低字符会变得很暗。这种场景下直接取 R 通道反而对比度最高值得试一下。2.2 阈值不该靠肉眼拖用直方图统计量来定手动拖阈值滑块是 Demo 阶段的做法正式工位必须让阈值可计算、可复现。最基础的是 Otsu它假设图像是双峰的自动找类间方差最大的分割点。对于字符和背景占比接近、光照均匀的场景Otsu 的效果相当好一行代码就能上Cv2.Threshold(gray, bin, 0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu);但 Otsu 有个明确的失效条件字符在画面里占的比例太小。比如一个 2000×1500 的画面里只有一小块 200×60 的字符区域背景占了 99% 的像素直方图基本是单峰的Otsu 算出来的阈值会偏向背景字符直接被吃掉。所以正确做法是先划 ROI只在字符所在的矩形区域里做统计和分割绝不要在全图上算全局阈值。ROI 内部的背景如果不均匀比如一侧打光强一侧弱就用自适应阈值Cv2.AdaptiveThreshold(grayRoi, bin, 255, AdaptiveThresholdTypes.GaussianC, ThresholdTypes.Binary, 31, 10);这里blockSize必须是奇数取多大取决于字符笔画宽度——经验值是笔画宽度的 3 到 5 倍31 对应笔画大概 7 到 10 像素的情况。常数项 C 是负向偏移值越大二值化结果越干净但也越容易丢细笔画从 5 开始试逐步加到字符刚好不断为止。对比度实在太低的场景先做一次 CLAHE 再二值化往往能把信噪比拉回来using var clahe Cv2.CreateCLAHE(clipLimit: 2.0, tileGridSize: new Size(8, 8)); clahe.Apply(grayRoi, enhanced);还有一个我认为必须有、但很多项目没做的动作图像质量前置判断。在 ROI 里算灰度直方图的 5% 和 95% 分位数两者之差就是实际的动态范围如果低于某个经验值比如 40直接判图像质量不合格报警不要硬着头皮往下识别。给操作员一个请检查光源的提示比输出一个错误的数字有价值得多。2.3 形态学算子用错方向等于自己制造粘连形态学在数字识别里有两个方向相反的用途用混了就是灾难。开运算先腐蚀后膨胀用来去毛刺和孤立噪点。二值化之后字符边缘常有细小的突起或者背景里有零星的白点这些在后续连通域分析时会变成多余的候选框。3×3 的矩形核跑一次开运算大部分毛刺就没了。闭运算先膨胀后腐蚀用来连接断笔画。点阵打印、激光刻印这类字符经常有断点笔画中间缺一两个像素。这里有个关键权衡闭运算的核越大、迭代次数越多断点接得越牢但相邻字符也越容易粘在一起。我的经验是只对单个字符内部做闭运算也就是在切分之后、识别之前做绝对不要在整行图像上做——整行上做的后果是一排字符糊成一条白带切分直接失效。using var kernel Cv2.GetStructuringElement(MorphShapes.Rect, new Size(2, 2)); Cv2.MorphologyEx(charRoi, cleaned, MorphTypes.Close, kernel);核的形状也值得注意。矩形核更适合点阵和印刷体因为笔画是水平垂直的椭圆形核会带来圆角对细长的斜笔画反而不好。除非字符是圆体字否则就用矩形。3. 字符切分投影法与连通域的分界线在哪里3.1 垂直投影法的实现细节和粘连处的两种切法垂直投影的思路很直白把二值图按列求和得到一个一维数组字符所在的位置列和值大字符间隙列和值接近零。寻找连续的非零区间每个区间就是一个字符。这种方法对等宽字体、字符间距稳定的场景几乎是完美的速度快、可解释、参数少。var colSum new int[bin.Width]; for (int x 0; x bin.Width; x) { int sum 0; for (int y 0; y bin.Height; y) if (bin.Atbyte(y, x) 0) sum; colSum[x] sum; } // 之后按 colSum 找连续非零段用高度的一定比例作为噪声下限判断零的时候不能死抠等于 0要给一个下限比如整列像素数的 2%否则一个噪点就能让两个字符的间隙消失。这个下限本身也是个参数得按实际噪声水平调。粘连是投影法的主要敌人。两个字符中间如果有一点点相连投影谷底就降不到阈值以下。处理方式有两种一种是设一个允许的最小谷值在这个谷内找最小值位置强行切开另一种更稳妥利用字符宽度先验——如果检测到的某个区间宽度明显大于其余字符的中位宽度比如超过 1.5 倍就按中位宽度把它等分成若干份。第二种方法在流水号这种等宽场景下成功率很高实现也简单。3.2 连通域方案在断笔画、噪点下的取舍连通域方案更通用Cv2.FindContours一次性拿到所有独立区块按外接矩形的面积、宽高比过滤掉噪点和过大的区域剩下的就当作字符候选。它对字符间距不敏感对大小不一的字符集也能处理。Cv2.FindContours(bin, out Point[][] contours, out HierarchyIndex[] hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); foreach (var c in contours) { var rect Cv2.BoundingRect(c); if (rect.Width minW || rect.Height minH) continue; // 面积、宽高比、填充率筛选 }它的软肋是断笔画。一个5如果横画断开了可能被拆成两个连通域结果就是多切出一个字符。这时候需要用纵向或横向的膨胀把它们合并或者用两个连通域的外接框在垂直方向重叠超过 70% 且水平间距小于某个值的规则做合并。反过来一个被油污连在一起的12用连通域就是切不开的只能靠宽度先验收割。所以两条路线怎么选其实取决于物料特性。字符是点阵喷码、笔画容易断连通域配合合并规则更稳字符是激光刻印、笔画连续但可能轻微粘连投影法配宽度先验更省事。我的项目里通常是两条路都实现配置里留个开关换料时按实际效果切。3.3 倾斜、透视与字符归一化别把归一化当可有可无的一步相机不可能永远正对着字符平面工件放上去就有两三度的倾斜字符整体是斜着排的。倾斜对切分影响不大——投影法在倾斜几度时谷底依然存在——但对识别影响很大因为送进模型的字符是歪的。处理方式是在 ROI 上做一次最小外接矩形校正或者用霍夫变换找字符行的角度然后Cv2.WarpAffine转正。透视变形更麻烦常见于斜视角拍摄的场景。如果工件表面有四个可定位的标记点用Cv2.GetPerspectiveTransform求出变换矩阵Cv2.WarpPerspective一次性做透视校正这一步做对了后面能省掉很多麻烦。然后是归一化这一步经常被跳过但它对识别率的影响比很多人想象的大。切出来的字符外接框大小不一有的 20×40有的 28×38。直接缩放到固定尺寸会破坏宽高比1会被拉成一个粗条。正确做法是保持宽高比缩放到目标框内比如统一缩到 20×20 的内框中再居中放到 28×28 的画布上四周留白。这套做法是从手写数字识别里沿用下来的用在印刷体上同样有效核心原因是它让同一个字符在不同大小、不同光照下的表现尽可能一致模型的负担小了很多。4. 三条识别路线的真实分水岭模板匹配、Tesseract、ONNX 推理4.1 模板匹配固定字体小字符集里性价比最高的一条路字符集只有 10 个数字、字体在整个生命周期内不变、位置相对固定这三个条件同时成立时模板匹配的性价比高得惊人。做法是给每个数字准备若干张模板图不同批次、不同磨损程度各一张把待识别字符归一化到统一尺寸后逐一比对取相似度最高的那个。Cv2.MatchTemplate(charNorm, template, result, TemplateMatchModes.CCoeffNormed); Cv2.MinMaxLoc(result, out double minVal, out double maxVal, out _, out _); // maxVal 即相似度CCoeffNormed的好处是对亮度线性变化不敏感正好抵消一部分光源漂移的影响。阈值怎么定把困难样本集里每个字符与正确模板的相似度都算出来取最小值再往下留一点余量比如最低是 0.87 就定 0.85。同时要看次高相似度与最高相似度的差值如果两者只差 0.01说明这个字符在两类模板之间摇摆应该判拒识。模板匹配的致命弱点是字体一变就全废而且模板数量会随着批次增加而膨胀维护成本上升。它适合那些产品型号少、生命周期长、字体固定的场景一旦涉及多型号混线就要考虑换路线了。4.2 Tesseract 的三个必调参数和两个必踩的坑Tesseract 的正确定位是开箱能跑调好能用但默认配置基本不能用。默认它按整页文档去理解图像会做版面分析、找文本块这在只有一个短字符串的 ROI 上完全是帮倒忙。第一个必调的是分割模式直接告诉它这是一行或一个词using var engine new TesseractEngine(./tessdata, eng, EngineMode.LstmOnly); engine.SetVariable(tessedit_char_whitelist, 0123456789); engine.SetVariable(classify_bln_numeric_mode, 1); using var page engine.Process(pix, PageSegMode.SingleLine); string text page.GetText().Trim(); float conf page.GetMeanConfidence();第二个必调的是字符白名单这可能是投入产出比最高的一个参数。不加白名单时Tesseract 会把0读成O、把1读成l或|、把5读成S加了只允许数字之后这类错误直接消失。第三个必调的是输入尺寸——Tesseract 对字符的 x-height 相当敏感字符高度低于 20 像素时识别率会断崖式下跌建议在送入前把字符缩放到 30 到 40 像素高用三次插值别用最近邻。两个必踩的坑一是tessdata目录的路径训练数据文件找不到时它不会明确报错只会识别出一堆乱码排查起来很浪费时间。二是线程安全TesseractEngine不是线程安全的多工位并发时每个线程必须持有独立实例共享一个实例在某些情况下会直接崩溃。而每个实例初始化都要加载语言数据几百毫秒起步所以要用对象池提前把实例备好别在触发回调里现 new。还有个隐性的坑Tesseract 默认预期白底黑字。如果你的二值化结果是黑底白字识别率会莫名其妙地低加一行Cv2.BitwiseNot反色就能解决。4.3 自训练小模型 ONNX Runtime什么时候值得上当字体多、批次杂、现场没法保证成像一致性时前两条路都会开始吃力这时候上一个自训练的小型卷积网络是最稳的。不需要大模型四层卷积加两层全连接的规模参数量几十万单张推理在普通工控机上只要几毫秒识别率却能甩开模板匹配一大截。训练数据的来源就是现场采集。做法是把工位跑起来把切分后的每一张字符小图连同识别结果一起落盘攒个一两周就有了几千张真实样本。数据增强主要做四件事亮度和对比度的随机扰动、±5 度内的轻微旋转、1 到 2 像素的平移、少量的高斯噪声和模糊。这几样加起来每个类别五百到一千张就足够训出一个能用的模型了。推理侧用Microsoft.ML.OnnxRuntime核心就三行var opts new SessionOptions { IntraOpNumThreads 1 }; using var session new InferenceSession(modelPath, opts); var tensor new DenseTensorfloat(new[] { 1, 1, 28, 28 }); // 填充归一化到 0~1 的像素值 using var results session.Run(new[] { NamedOnnxValue.CreateFromTensor(input, tensor) }); var scores results.First().AsTensorfloat().ToArray();IntraOpNumThreads 1这个设置很关键。默认它会让 ONNX Runtime 自己开线程池在多工位并发、每个工位一个 session 的情况下线程数会爆炸式增长反而拖慢整体。每个 session 限制单线程靠上层的并行去压满 CPU实测下来总吞吐更高也更稳定。输出层用 softmax 转成概率最高概率低于阈值比如 0.9就输出拒识而不是硬给一个答案。这个拒识机制是从业者和业余方案之间最明显的分界线之一。5. 塞进产线节拍C# 侧的内存、并发与耗时定位5.1 Mat 与 Bitmap 的生命周期是内存泄漏的头号来源Mat持有非托管内存虽然实现了IDisposable但 GC 并不知道它占了多少内存所以在内存压力下不会优先回收它表现就是托管堆看着不大进程占用却一直涨。解决办法没有捷径就是每一个new Mat都要有对应的释放。在方法内部用using声明是最省心的方式using var gray new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY);如果性能敏感、需要复用缓冲区那就把Mat提成类的字段在构造函数里创建一次在Dispose里统一释放处理时用Cv2.CvtColor(src, gray, ...)这种带输出参数的重载写回已有对象避免反复分配。连续跑八小时内存不涨才算过关。Bitmap那边同理LockBits和UnlockBits必须成对而且UnlockBits要放在finally里否则中间任何一次异常都会让这块位图永久锁死后续访问直接抛异常。5.2 多工位并发时别让引擎实例成为瓶颈一台工控机带四个相机工位是常见配置。这时候要区分哪些资源可以共享、哪些必须独占。Mat缓冲区不能共享每个工位一套OpenCV 的大部分函数本身是可重入的但为了保险起见涉及到内部缓存的函数比如某些形态学操作还是各自持有核对象更稳。识别引擎是重点。Tesseract 实例必须独占用对象池按工位数量准备。ONNX 的InferenceSession本身是线程安全的可以被多个线程同时调用但如前所述配合IntraOpNumThreads1时一个共享 session 加外部并行往往比每工位一个 session 更划算具体哪个好要实测。我的做法是先用共享 session 跑一遍压力测试看单件耗时和 CPU 占用不满足再改成独占。并发模型上Parallel.For处理批次任务、每个工位一个专用线程处理连续流这两种都比线程池随手丢任务更可控。相机回调里绝对不要做重活回调只负责把数据拷进队列就返回处理放到独立线程否则会拖慢相机出图节奏导致丢帧。5.3 耗时打点怎么做才有意义线上性能问题排查靠猜是最没效率的。我的习惯是在每个环节插Stopwatch打点取图、转换、预处理、切分、归一化、推理每一段单独计时累积到一千次之后输出一次分位数统计P50、P95、P99。平均值会掩盖问题真正影响体验的是 P99。打点本身也有成本。用Stopwatch.GetTimestamp()拿时间戳比DateTime.Now快一个数量级DateTime.Now内部有时区转换高频调用时会明显拖慢。如果打点频率很高还要注意别在热路径上做字符串拼接和日志输出先存到结构体数组里定期批量落盘。优化顺序上经验是从后往前找。先看推理再看完备性检查然后是归一化最后才是预处理。因为预处理往往是最容易顺手多做几步的地方各种滤波、多次形态学、反复的深拷贝堆在一起单看每步都不贵加起来能占掉一半时间。6. 上线之后被追问最多的几件事6.1 换批次就崩补偿思路有哪些换批次崩掉的本质是对比度分布变了。最省事的补偿是每次换料时让操作员点一下学习按钮系统在当前图像上重新算一遍 Otsu 阈值和 ROI 位置存成该批次的配置。这个做法成本低但对操作员有要求容易忘。更自动一点的做法是在线监控每处理 N 件就统计一次 ROI 的动态范围和二值化后的前景占比如果偏离基线超过一定比例自动触发阈值重算同时在界面上留一条记录。重算不是无条件的要设一个合理区间防止把一块空白区域当成新的基准。再往上一层是硬件侧。字符是刻印的用低角度环形光能把刻痕打出阴影对比度天然比正打光高字符是喷墨的用同轴光或者漫射光能减少反光。打光方案定对了软件侧的容错空间会大很多我在项目里会优先建议在打光上花时间而不是在算法上硬堆。6.2 相似字符混淆0/8、6/5、1/7 的处理策略工业场景里的混淆对基本就那几组0 和 8都是闭合环、6 和 5下半部相似、1 和 7一个竖一个带横。这些混淆的根源常常不是模型不行而是分辨率不够——0 和 8 的区别在于中间那道横线如果字符高度只有 15 像素、横线本身只有 1 像素宽二值化时稍微一抖就没了。处理思路按投入排序第一步先提分辨率加镜头放大倍率或者缩短工作距离让字符高度到 30 像素以上很多混淆自动就解决了第二步是提高二值化保真度用自适应阈值替代全局阈值保住细笔画第三步才是在样本上针对性补数据把历史上所有被判错的 0 和 8 收集起来放进训练集让模型专门学这几组的差异。走完这三步还有问题的基本就是物理层面看不清了该考虑加拒识。6.3 拒识机制宁可报警不要硬给一个错答案这一条是我最想强调的。识别系统输出一个错误数字比输出读不出来的危害大得多——错误数据会流进下游系统可能引发一连串连锁问题而且事后追溯极其困难而读不出来只是个流程中断人工补录一下就能过去。所以设计上永远要留一个置信度门槛。模板匹配看最高和次高分的差值Tesseract 看GetMeanConfidence()的返回值ONNX 看 softmax 后的最大概率。任何一个低于预设阈值就输出拒识同时把这张图和中间结果一起存下来供事后分析。这些被拒识的图是后续迭代最宝贵的素材基本上攒够一两百张、重新训练一轮拒识率就能降下来一截。我自己的习惯是在配置里把阈值做成可以现场调的但加个下限保护防止为了让画面好看把阈值拖到 0.5 以下那样系统就等于没有拒识只是把误判藏起来了。真正的稳定不是识别率高是错得少且错在哪里都知道。
阅读完成 · 觉得有帮助?
咨询建站