ARTICLE DETAIL

资讯详情

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

C#上位机实战:车牌识别与验证码识别全链路落地

C#上位机实战:车牌识别与验证码识别全链路落地 简介这是一套基于C#开发的字符与车牌识别项目源码面向计算机视觉入门者、.NET开发者及需要实现验证码或车牌号自动识别的技术人员。项目围绕图像预处理、特征提取与字符分类展开验证码识别部分涉及灰度化、二值化、噪声去除与模板匹配车牌识别则结合目标检测与OCR思路可应用于自动车牌管理、交通监控及网络安全等场景。资源包共66个文件以xml文档、dll类库、cs源码为主另含resx资源、csproj工程与sln解决方案整体约1.27MB其中AForge系列组件承担图像处理与机器学习基础能力AspriseOCR.dll提供字符识别支持源码文件便于二次开发与调试。目前已有563人学习下载。读者可从中获取完整的识别工程结构、图像处理与OCR调用示例以及将验证码与车牌识别整合落地的参考实现适合作为课程设计、毕业设计或自研识别工具的基础模板。1. 从一张糊到看不清的图说起C# 里把车牌和验证码识别跑通到底难在哪夜里十一点收费站出口的相机回传一张 JPEG车牌被远光灯糊掉一半旁边还贴着一张四位扭曲字母的验证码。业务方要的是车牌号识别出来、验证码识别出来全部塞进一个 C# 上位机里别整 Python 服务再转 HTTP。这个需求听起来像两个任务其实底层是同一件事——把一张图里的字符序列抠出来。车牌识别要处理的是固定版式、强透视、光照不均验证码识别要处理的是随机扭曲、粘连、干扰线。两者共用一套「预处理 → 定位 → 切分/序列建模 → 解码」的骨架只是参数和模型选型分叉。这篇东西写给正在用 C# 做上位机、工控视觉、停车场系统或者被验证码卡住登录流程的工程师。不聊虚的从环境怎么搭、模型怎么选、参数怎么调一路讲到我在现场翻过的车。看完你应该能判断这个方向值不值得投入以及第一版最小可跑通的代码长什么样。2. 车牌识别在 C# 里的完整落地链路从 OpenCVSharp 到字符解码2.1 为什么车牌识别优先选 OpenCVSharp 轻量检测而不是直接上大模型车牌识别这个场景有个特点版式固定。中国大陆蓝牌是 440×140 比例新能源绿牌是 480×140黄牌、白牌各有规格。这意味着你不需要一个通用目标检测大模型去「理解」什么是车牌只需要一个能在固定 ROI 里稳定框出矩形区域的轻量检测器。我一般会这么分层第一层用颜色和边缘做粗定位把候选区域从整帧里切出来第二层用一个小型 CNN 或者模板匹配做精定位拿到四个角点第三层做透视矫正把倾斜的车牌拉正第四层才是字符分割和识别。这套链路在 C# 里用 OpenCVSharp 就能全部搞定不需要跨语言调用。选 OpenCVSharp 而不是 EmguCV 的原因很实际OpenCVSharp 的 NuGet 包更新更勤API 更贴近原生 OpenCV网上 C 的示例改一改就能用。EmguCV 封装层厚出问题的时候你调试的是封装而不是算法本身。至于为什么不用 Python 训练再 C# 推理——可以但部署时你会多一个进程间通信的坑上位机现场最怕的就是多进程。# 在项目里装 OpenCVSharp注意运行时包要一起装 dotnet add package OpenCvSharp4 dotnet add package OpenCvSharp4.runtime.win这两行是基础。OpenCvSharp4是托管封装OpenCvSharp4.runtime.win是原生 DLL。很多人只装第一个运行时报DllNotFoundException就是这个原因。Linux 上换成对应的 runtime 包。2.2 车牌定位的三个必调参数HSV 阈值、形态学核、最小外接矩形粗定位阶段HSV 颜色空间比 RGB 稳。蓝牌的 H 通道大概在 100 到 124 之间S 通道大于 80V 通道大于 50。这三个阈值不是拍脑袋是拿几十张现场图统计出来的。但现场光照一变阈值就得跟着动所以我会加一个自适应先算整帧的平均 V如果偏暗就把 V 下限降到 40。using OpenCvSharp; public Rect[] LocatePlate(Mat src) { // 转 HSV蓝牌定位 var hsv new Mat(); Cv2.CvtColor(src, hsv, ColorConversionCodes.BGR2HSV); // 蓝色范围H 100-124, S 80-255, V 50-255 var lower new Scalar(100, 80, 50); var upper new Scalar(124, 255, 255); var mask new Mat(); Cv2.InRange(hsv, lower, upper, mask); // 形态学闭运算把车牌区域连成块 // 核大小 17x5 是经验值横向拉长因为车牌是扁的 var kernel Cv2.GetStructuringElement( MorphShapes.Rect, new Size(17, 5)); Cv2.MorphologyEx(mask, mask, MorphTypes.Close, kernel); // 找轮廓筛面积和宽高比 Cv2.FindContours(mask, out var contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple); var results new ListRect(); foreach (var c in contours) { var rect Cv2.BoundingRect(c); double ratio (double)rect.Width / rect.Height; // 车牌宽高比大概 2.5 到 5.5 之间 if (ratio 2.5 ratio 5.5 rect.Width 80) results.Add(rect); } return results.ToArray(); }逻辑说明InRange把蓝色区域变成二值图MorphologyEx的闭运算把断裂的车牌字符连成一块FindContours找外轮廓最后用宽高比过滤掉非车牌区域。参数上形态学核的宽度决定能连多远的断裂17 是能覆盖大多数蓝牌字符间距的值高度 5 是为了不把上下相邻的干扰连进来。宽高比下限 2.5 是防止把方形广告牌误判上限 5.5 是防止把长条护栏误判。这里有个坑如果现场有大量蓝色车身HSV 定位会疯狂误检。解决办法是加一层「车牌内部有字符跳变」的验证——在候选矩形里做垂直边缘检测字符区域边缘密度明显高于纯色车身。2.3 透视矫正与字符分割把斜着的车牌拉正再切字定位拿到的矩形往往是斜的直接切字符会切歪。透视矫正需要四个角点MinAreaRect给的是旋转矩形取它的四个顶点就行。public Mat WarpPlate(Mat src, RotatedRect rr) { var pts rr.Points(); // 四个角点 // 按 xy 和 x-y 排序确定左上、右上、右下、左下 var ordered pts.OrderBy(p p.X p.Y).ToArray(); var tl ordered[0]; var br ordered[3]; var tr ordered.OrderBy(p p.X - p.Y).Last(); var bl ordered.OrderBy(p p.X - p.Y).First(); // 目标宽高蓝牌 440x140 var dst new Point2f[] { new Point2f(0, 0), new Point2f(440, 0), new Point2f(440, 140), new Point2f(0, 140) }; var srcPts new Point2f[] { tl, tr, br, bl }; var m Cv2.GetPerspectiveTransform(srcPts, dst); var result new Mat(); Cv2.WarpPerspective(src, result, m, new Size(440, 140)); return result; }角点排序是这里最容易翻车的地方。OrderBy(p p.X p.Y)拿左上和右下是稳的但右上和左下用X - Y排序时如果车牌旋转超过 45 度会错位。现场经验先判断rr.Angle如果绝对值大于 45先把图转正再排序。矫正之后是字符分割。蓝牌是 7 个字符第一个是汉字后面是字母数字。常见做法是垂直投影对二值图按列求和字符之间的间隙处投影值接近零以此为切分点。public ListRect SplitChars(Mat binary) { // 垂直投影 var colSum new int[binary.Cols]; for (int x 0; x binary.Cols; x) { int sum 0; for (int y 0; y binary.Rows; y) if (binary.Atbyte(y, x) 0) sum; colSum[x] sum; } // 找连续非零段 var segments new ListRect(); int start -1; for (int x 0; x colSum.Length; x) { if (colSum[x] 2 start -1) start x; else if (colSum[x] 2 start ! -1) { // 宽度太小的当噪声丢掉 if (x - start 8) segments.Add(new Rect(start, 0, x - start, binary.Rows)); start -1; } } return segments; }阈值 2 是投影值的下限低于它认为是间隙。宽度 8 是字符最小宽度小于它的段当噪声。这两个值要根据你的矫正后图像分辨率调440 宽的图用这套参数没问题如果矫正到 220 宽阈值要减半。分割完的字符送进识别模型。识别模型的选择上如果只做蓝牌一个 7 分类的 CNN 就够如果要做多省份汉字建议用 CRNN 做序列识别省去分割这一步。CRNN 在 C# 里可以用 ONNX Runtime 加载训练在 Python 侧完成。3. 验证码识别从二值化到 CRNN 序列建模的 C# 实现3.1 验证码识别的预处理三板斧灰度、二值化、去噪验证码和车牌最大的区别是验证码没有固定版式字符数量、字体、扭曲程度都是随机的。所以预处理不能像车牌那样依赖颜色得靠灰度。第一步灰度化Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY)。第二步二值化这里有个选择全局阈值还是自适应阈值。验证码背景通常有噪点全局阈值容易把背景噪点也变成前景。我一般用Cv2.AdaptiveThreshold块大小取 11 或 15C 值取 2 到 5。public Mat PreprocessCaptcha(Mat src) { var gray new Mat(); Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY); // 自适应二值化块大小 11C3 var binary new Mat(); Cv2.AdaptiveThreshold(gray, binary, 255, AdaptiveThresholdTypes.GaussianC, ThresholdTypes.Binary, 11, 3); // 中值滤波去椒盐噪声 Cv2.MedianBlur(binary, binary, 3); // 如果字符是深色背景浅色反相 // 统计前景像素占比超过 50% 说明反了 int whiteCount Cv2.CountNonZero(binary); if (whiteCount binary.Rows * binary.Cols / 2) Cv2.BitwiseNot(binary, binary); return binary; }块大小 11 意味着每个像素的阈值由周围 11×11 区域决定这个尺寸能适应验证码背景的渐变。C 值 3 是从均值里减去的常数越大二值化越激进。中值滤波核 3 能去掉细小的干扰点但不会像高斯滤波那样把字符边缘糊掉。反相判断这一步很多人会漏。有些验证码是深底浅字二值化后前景是黑的直接送模型会学反。统计白色像素占比是最简单的判断方法。3.2 用 ONNX Runtime 在 C# 里跑 CRNN模型加载与推理代码验证码识别不建议自己做字符分割因为扭曲和粘连会让分割算法崩溃。CRNN 把整张图当成一个序列来读输入是高度归一化后的图输出是字符序列。模型训练在 Python 侧用 PyTorch 完成导出 ONNX。C# 侧用Microsoft.ML.OnnxRuntime加载。dotnet add package Microsoft.ML.OnnxRuntime dotnet add package Microsoft.ML.OnnxRuntime.Managedusing Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class CrnnRecognizer { private InferenceSession _session; private string _charset 0123456789abcdefghijklmnopqrstuvwxyz; public CrnnRecognizer(string modelPath) { _session new InferenceSession(modelPath); } public string Recognize(Mat binary) { // 归一化到高 32宽按比例缩放最大 128 int targetH 32; int targetW Math.Min(128, (int)(binary.Cols * (double)targetH / binary.Rows)); var resized new Mat(); Cv2.Resize(binary, resized, new Size(targetW, targetH)); // 转 float 张量形状 [1, 1, 32, W] var input new DenseTensorfloat( new[] { 1, 1, targetH, targetW }); for (int y 0; y targetH; y) for (int x 0; x targetW; x) input[0, 0, y, x] resized.Atbyte(y, x) / 255f; var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, input) }; using var results _session.Run(inputs); // 输出形状 [T, 1, num_classes]做 CTC 解码 var output results.First().AsTensorfloat(); return CtcDecode(output); } private string CtcDecode(Tensorfloat output) { int T output.Dimensions[0]; int numClasses output.Dimensions[2]; var sb new System.Text.StringBuilder(); int prev -1; for (int t 0; t T; t) { int best 0; float maxVal float.MinValue; for (int c 0; c numClasses; c) { float v output[t, 0, c]; if (v maxVal) { maxVal v; best c; } } // CTC跳过 blank假设 0 是 blank和重复 if (best ! 0 best ! prev) sb.Append(_charset[best - 1]); prev best; } return sb.ToString(); } }逻辑说明输入张量的形状必须和训练时一致[1, 1, 32, W]是 batch、channel、height、width。像素值除以 255 归一化到 0 到 1。CTC 解码的核心是合并重复字符并去掉 blank 标签blank 通常是索引 0。参数上targetH 32是 CRNN 的常见输入高度训练时也是这个值不能改。targetW按比例缩放但不超过 128超过就截断。如果你的验证码最长 6 位128 宽足够。这里有个性能注意点InferenceSession创建一次就复用不要每次识别都 new。我见过有人在循环里 new session识别 100 张图花了 30 秒改成复用后 1 秒不到。3.3 验证码识别的准确率边界什么情况下该放弃端到端模型CRNN 不是万能的。如果验证码满足以下任一条件端到端模型的准确率会掉到 60% 以下这时候要考虑别的路子第一字符严重粘连两个字符的笔画完全连在一起。CRNN 的 CTC 对这种情况的处理依赖训练数据里有没有类似样本没有就崩。第二背景干扰线穿过字符主体。干扰线和字符颜色相近时二值化后无法分离。第三字符有旋转。CRNN 对小幅旋转鲁棒超过 30 度就不行了。遇到这些情况我的做法是先做数据增强把训练样本做随机旋转、加干扰线、做弹性形变让模型见过这些情况。如果增强后还是不行说明这个验证码的设计就是为了防自动识别这时候应该考虑人工打码或者换业务接口而不是硬刚模型。4. 避坑与排查车牌和验证码识别在现场最容易翻车的五个点4.1 现象白天识别率 95%晚上掉到 40%原因HSV 阈值是拿白天样本调的晚上整体 V 值偏低蓝色区域落到了阈值外面。解决加自适应 V 下限。先算整帧的平均 V如果低于 80把 V 下限从 50 降到 30同时把 S 下限从 80 降到 60。更稳的做法是加一个补光灯触发逻辑检测到环境亮度过低时打开补光。4.2 现象OpenCVSharp 在客户机器上报 DllNotFoundException原因只装了OpenCvSharp4托管包没装原生运行时包。解决dotnet add package OpenCvSharp4.runtime.win。如果是 Linux装OpenCvSharp4.runtime.linux-x64。发布时确认原生 DLL 被复制到输出目录检查.csproj里有没有CopyLocalLockFileAssembliestrue/CopyLocalLockFileAssemblies。4.3 现象CRNN 推理结果全是重复字符原因CTC 解码时没有正确跳过 blank或者 blank 的索引不是 0。解决确认训练时 blank 的索引。PyTorch 的CTCLoss默认 blank 是 0但如果你在定义 charset 时把 0 留给了别的字符就会错位。检查模型输出维度numClasses应该等于charset.Length 1。4.4 现象车牌字符分割把「川」字切成两半原因垂直投影对左右结构的汉字不友好「川」字中间有间隙投影值会掉到阈值以下。解决对第一个字符汉字不做投影分割直接按固定宽度切。蓝牌第一个字符占约 1/7 宽度按比例切就行。或者用 CRNN 做整牌识别跳过分割。4.5 现象验证码识别模型在测试集 99%上线后 50%原因测试集和线上数据分布不一致。测试集是从同一个来源采的线上验证码换了字体或加了新的干扰样式。解决上线前用线上真实数据做一次验证不要只用测试集。如果分布差异大做在线学习或者定期用新数据微调。我一般会留 10% 的线上数据做监控准确率掉超过 10 个点就触发告警。5. 把两个识别任务塞进一个 C# 上位机工程化收尾的几个技巧先说一个我踩过的坑车牌识别和验证码识别虽然底层都是 OCR但它们的模型输入尺寸、预处理流程、后处理逻辑完全不同。我一开始想用一个统一的ICharRecognizer接口把两者抽象到一起结果接口方法签名改了五版还是别扭。后来放弃了两个独立的类各自管各自的预处理和模型代码反而清晰。工程化上有几个习惯能省很多事。第一所有图像处理步骤都留中间结果输出用Cv2.ImWrite存到临时目录出问题时能一眼看出是哪一步崩的。第二模型路径不要硬编码放到配置文件里现场换模型不用重新编译。第三推理加超时InferenceSession.Run在极端情况下会卡住用Task.Run包一层加CancellationToken。public async Taskstring RecognizeWithTimeout( Mat image, int timeoutMs 500) { using var cts new CancellationTokenSource(timeoutMs); var task Task.Run(() _recognizer.Recognize(image), cts.Token); var completed await Task.WhenAny(task, Task.Delay(timeoutMs, cts.Token)); if (completed ! task) throw new TimeoutException(识别超时); return await task; }验证方法上我习惯做一个「回归测试集」把现场遇到过的所有翻车样本存下来每次改预处理参数或换模型先跑一遍这个集子确认没有退化。这个集子不用大50 到 100 张就够但必须覆盖各种光照、角度、干扰情况。最后说一个判断标准如果车牌识别在白天能做到 98% 以上、晚上 90% 以上验证码识别在固定样式下能做到 95% 以上这个方案就值得投入生产。如果验证码识别怎么调都上不了 80%别硬刚换业务方案。我在一个项目上花了三周调验证码模型最后发现对方接口其实支持短信验证三周白干。这个教训我记到现在先确认技术方案是不是唯一路径再投入调参。希望帮到你。本文还有配套的精品资源点击获取
返回列表