C# OnnxRuntime 实现车牌检测识别

0 阅读9分钟

说明

支持 单层、双层车牌

支持 中文车牌字符识别(含“京、沪、粤、学、警”等全部字符)

内置的 PLATE_CHARS 一次性覆盖了国内车管所公布的全部 74 个字符:

# 省份简称
京、津、冀、晋、蒙、辽、吉、黑、沪、苏、浙、皖、闽、赣、鲁、豫、鄂、
湘、粤、桂、琼、川、贵、云、藏、陕、甘、青、宁、新
# 特殊用途
学、警、港、澳、挂、使、领、民、航、危
# 英文字母(I、O 除外)
ABCDEFGHJKLMNPQRSTUVWXYZ
# 数字
0-9

所有代码写在一个 Form1.cs 里,没有类库跳转,没有复杂架构

每个方法都配了 中文注释,小学生也能看懂在做什么

复制代码 → 装两个 NuGet 包 → 放两个模型 → 跑起来,全程不超过 3 分钟

不需要了解 Python、不需要碰命令行、不需要配 Anaconda,真正零环境依赖。

效果

图片
图片
图片
图片

模型信息

car_plate_detect.onnx

Model Properties
-------------------------
---------------------------------------------------------------

Inputs
-------------------------
name:images
tensor:Float[1, 3, 48, 168]
---------------------------------------------------------------

Outputs
-------------------------
name:output
tensor:Float[1, 21, 78]
---------------------------------------------------------------

plate_rec.onnx

Model Properties
-------------------------
---------------------------------------------------------------

Inputs
-------------------------
name:images
tensor:Float[1, 3, 48, 168]
---------------------------------------------------------------

Outputs
-------------------------
name:output
tensor:Float[1, 21, 78]
---------------------------------------------------------------

项目

图片

代码逻辑速览

整个推理流程可以切成 8 个步骤,我们跟着 button2_Click 走一趟:

1、读取图片 (Mat img0)

2、检测预处理 – 调用 Letterbox 将图片等比缩放并补齐灰边,再转为 RGB、归一化,做成 DenseTensor送入检测模型。

3、运行检测模型 – 拿到 (1, 25200, 16) 的输出,包含候选框的 xywh、置信度、四个角点坐标、单/双层类别。

4、后处理 – 过滤低置信度框 → 手写 NMS 去重 → 通过 ratio/left/top 恢复原图坐标。

5、四点透视变换 – 用 FourPointTransform 把歪斜的车牌抠出来“摆正”,保证识别模型不吃亏。

6、双层处理 – 如果检测为双层车牌,自动切割上下两行并水平拼接,让识别模型只看到一排文字。

7、文字识别 – 将矫正后的车牌缩放到 168×48,标准化(减均值除标准差),送入识别模型;输出 CTC 序列,用 DecodePlate 解码成最终车牌号。

8、GDI+ 画图 – 在原图上用红色矩形框出车牌,用白色文字标注结果;同时把详细信息输出到文本框。

代码

using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenCvSharp;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace Onnx_Demo
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        string fileFilter"*.*|*.bmp;*.jpg;*.jpeg;*.tiff;*.tiff;*.png";
        string image_path"";
        string startupPath;
        DateTime dt1 = DateTime.Now;
        DateTime dt2 = DateTime.Now;
        Mat image;
        Mat result_image;

        // 车牌检测模型
        private InferenceSession session_detect;
        private string detect_model_path;
        // 车牌识别模型
        private InferenceSession session_rec;
        private string rec_model_path;

        private const float MEAN_VALUE = 0.588f;
        private const float STD_VALUE = 0.193f;
        private readonly string PLATE_CHARS ="#京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新学警港澳挂使领民航危0123456789ABCDEFGHJKLMNPQRSTUVWXYZ险品";

        private void button1_Click(object sender, EventArgs e)
        {
            OpenFileDialog ofd = new OpenFileDialog();
            ofd.Filter = fileFilter;
            ofd.InitialDirectory = System.IO.Path.Combine(System.Windows.Forms.Application.StartupPath, "test_img");
            if (ofd.ShowDialog() != DialogResult.OK) return;
            pictureBox1.Image = null;
            image_path = ofd.FileName;
            pictureBox1.Image = new Bitmap(image_path);
            textBox1.Text"";
            image = new Mat(image_path);
            pictureBox2.Image = null;
        }

        private void button2_Click(object sender, EventArgs e)
        {
            if (string.IsNullOrEmpty(image_path))
                return;

            button2.Enabledfalse;
            pictureBox2.Image = null;
            textBox1.Text"";
            Application.DoEvents();

            // 读取原图
            Mat img0 = new Mat(image_path);
            if (img0.Empty())
            {
                button2.Enabledtrue;
                return;
            }

            // 深拷贝一份用于绘制(BGR)
            Mat drawImg = img0.Clone();

            // ---------- 检测预处理 ----------
            Mat blob;
            float ratio;
            int padLeft, padTop;
            Tensor<float> inputTensor = PreprocessDetect(img0, 640, 640, out blob, out ratio, out padLeft, out padTop);

            // ---------- 运行检测模型 ----------
            dt1 = DateTime.Now;
            var container = new List<NamedOnnxValue>
            {
                NamedOnnxValue.CreateFromTensor("input", inputTensor)
            };
            List<PlateResult> plateResults = new List<PlateResult>();
            using (var results = session_detect.Run(container))
            {
                var outputTensor = results.First().AsTensor<float>();
                dt2 = DateTime.Now;

                // 后处理:解析检测框
                List<float[]> detBoxes = PostprocessDetect(outputTensor, ratio, padLeft, padTop, 0.4f, 0.5f);
                // 识别并收集结果
                foreach (var box in detBoxes)
                {
                    float x1 = box[0], y1 = box[1], x2 = box[2], y2 = box[3];
                    float score = box[4];
                    int label = (int)box[13];
                    // 提取四个角点
                    Point2f[] landmarks = new Point2f[4];
                    for (int k = 0; k < 4; k++)
                    {
                        landmarks[k] = new Point2f(box[5 + 2 * k], box[6 + 2 * k]);
                    }

                    // 四点透视变换获取校正车牌图
                    Mat roiImg = FourPointTransform(img0, landmarks);
                    if (roiImg.Empty())
                        continue;

                    string plateType = label == 1"双层" : "单层";
                    if (label == 1)
                    {
                        roiImg = SplitMerge(roiImg);
                        if (roiImg.Empty())
                            continue;
                    }

                    // 识别车牌文字
                    string plateText = RecognizeText(roiImg);
                    roiImg.Dispose();

                    plateResults.Add(new PlateResult
                    {
                        X1 = x1,
                        Y1 = y1,
                        X2 = x2,
                        Y2 = y2,
                        Score = score,
                        Plate = plateText,
                        Type = plateType
                    });
                }

                // ---- 构建文本框输出信息 ----
                StringBuilder sb = new StringBuilder();
                sb.AppendLine("推理耗时:" + (dt2 - dt1).TotalMilliseconds.ToString("F2") + "ms");
                sb.AppendLine("检测到 " + plateResults.Count + " 个车牌:");
                for (int i = 0; i < plateResults.Count; i++)
                {
                    var pr = plateResults[i];
                    sb.AppendLine(string.Format("{0}. [{1}] {2} 置信度:{3:F2} 坐标:({4:F0},{5:F0},{6:F0},{7:F0})",
                        i + 1, pr.Plate, pr.Type, pr.Score, pr.X1, pr.Y1, pr.X2, pr.Y2));
                }
                textBox1.Text = sb.ToString();

                // ---------- 绘制结果到 drawImg ----------
                byte[] imgBytes;
                Cv2.ImEncode(".png", drawImg, out imgBytes);
                using (MemoryStream ms = new MemoryStream(imgBytes))
                {
                    Bitmap bmp = (Bitmap)Image.FromStream(ms);
                    drawImg.Dispose();  // 释放已编码的 Mat

                    using (Graphics g = Graphics.FromImage(bmp))
                    {
                        foreach (var pr in plateResults)
                        {
                            int ix1 = (int)Math.Max(0, pr.X1);
                            int iy1 = (int)Math.Max(0, pr.Y1);
                            int ix2 = (int)Math.Min(bmp.Width - 1, pr.X2);
                            int iy2 = (int)Math.Min(bmp.Height - 1, pr.Y2);
                            if (ix2 <= ix1 || iy2 <= iy1) continue;

                            // 画红色矩形框
                            using (Pen pen = new Pen(Color.Red, 3))
                            {
                                g.DrawRectangle(pen, ix1, iy1, ix2 - ix1, iy2 - iy1);
                            }

                            // 绘制车牌文字(带背景色)
                            string text = string.Format("{0} ({1}) {2:F2}", pr.Plate, pr.Type, pr.Score);
                            Font font = new Font("Microsoft YaHei", 16, FontStyle.Bold);
                            SizeF textSize = g.MeasureString(text, font);
                            float textX = ix1;
                            float textY = iy1 - textSize.Height - 4;
                            if (textY < 0) textY = iy1 + 4;

                            using (Brush bgBrush = new SolidBrush(Color.Red))
                            {
                                g.FillRectangle(bgBrush, textX, textY, textSize.Width + 4, textSize.Height + 4);
                            }
                            using (Brush textBrush = new SolidBrush(Color.White))
                            {
                                g.DrawString(text, font, textBrush, textX + 2, textY + 2);
                            }
                        }
                    }

                    pictureBox2.Image = bmp;   // bmp 已包含绘制的框和文字
                }

                // 释放预处理时创建的临时 
                blob.Dispose();
            }

            img0.Dispose();
            button2.Enabledtrue;
        }

        // ---------------------- 下面为所有辅助方法(Letterbox、检测预处理/后处理、NMS、四点变换等)---------------------

        private (Mat boxed, float ratio, int left, int top) Letterbox(Mat img, int targetW, int targetH)
        {
            int height = img.Height, width = img.Width;
            float ratio = Math.Min((float)targetH / height, (float)targetW / width);
            int newW = (int)(width * ratio);
            int newH = (int)(height * ratio);
            int left = (targetW - newW) / 2;
            int top = (targetH - newH) / 2;
            int right = targetW - newW - left;
            int bottom = targetH - newH - top;

            Mat resized = img.Resize(new OpenCvSharp.Size(newW, newH));
            Mat boxed = new Mat();
            Cv2.CopyMakeBorder(resized, boxed, top, bottom, left, right, BorderTypes.Constant, new Scalar(114, 114, 114));
            resized.Dispose();
            return (boxed, ratio, left, top);
        }

        private Tensor<float> PreprocessDetect(Mat img, int targetW, int targetH,
            out Mat blob, out float ratio, out int padLeft, out int padTop)
        {
            var (boxed, r, left, top) = Letterbox(img, targetW, targetH);
            blob = boxed;
            ratio = r;
            padLeft = left;
            padTop = top;

            // 转换为 RGB,归一化
            Mat rgb = new Mat();
            Cv2.CvtColor(blob, rgb, ColorConversionCodes.BGR2RGB);
            rgb.ConvertTo(rgb, MatType.CV_32FC3, 1.0 / 255.0);

            int h = rgb.Height, w = rgb.Width;
            var tensor = new DenseTensor<float>(new[] { 1, 3, h, w });
            for (int y = 0; y < h; y++)
            {
                for (int x = 0; x < w; x++)
                {
                    Vec3f vec = rgb.At<Vec3f>(y, x);
                    tensor[0, 0, y, x] = vec.Item0; // R
                    tensor[0, 1, y, x] = vec.Item1; // G
                    tensor[0, 2, y, x] = vec.Item2; // B
                }
            }
            rgb.Dispose();
            return tensor;
        }

        private List<float[]> PostprocessDetect(Tensor<float> output, float ratio, int left, int top,
            float confThresh, float iouThresh)
        {
            int numAnchors = output.Dimensions[1];
            int numValues = output.Dimensions[2];   // 16
            float[] raw = output.ToArray();

            List<float[]> candidateBoxes = new List<float[]>();
            for (int i = 0; i < numAnchors; i++)
            {
                int offset = i * numValues;
                float objConf = raw[offset + 4];
                if (objConf <= confThresh) continue;

                float cls0 = raw[offset + 13] * objConf;
                float cls1 = raw[offset + 14] * objConf;
                float maxScore = Math.Max(cls0, cls1);
                if (maxScore < confThresh) continue;
                int label = cls0 >= cls1 ? 0 : 1;

                float cx = raw[offset];
                float cy = raw[offset + 1];
                float w = raw[offset + 2];
                float h = raw[offset + 3];
                float x1 = cx - w / 2;
                float y1 = cy - h / 2;
                float x2 = cx + w / 2;
                float y2 = cy + h / 2;

                float lx0 = raw[offset + 5], ly0 = raw[offset + 6];
                float lx1 = raw[offset + 7], ly1 = raw[offset + 8];
                float lx2 = raw[offset + 9], ly2 = raw[offset + 10];
                float lx3 = raw[offset + 11], ly3 = raw[offset + 12];

                candidateBoxes.Add(new float[] {
                    x1, y1, x2, y2, maxScore,
                    lx0, ly0, lx1, ly1, lx2, ly2, lx3, ly3,
                    (float)label
                });
            }

            if (candidateBoxes.Count == 0)
                return new List<float[]>();

            List<float[]> nmsResult = Nms(candidateBoxes, iouThresh);
            List<float[]> finalBoxes = new List<float[]>();
            foreach (var box in nmsResult)
            {
                finalBoxes.Add(RestoreBox(box, ratio, left, top));
            }
            return finalBoxes;
        }

        private float ComputeIoU(float[] boxA, float[] boxB)
        {
            float ax1 = boxA[0], ay1 = boxA[1], ax2 = boxA[2], ay2 = boxA[3];
            float bx1 = boxB[0], by1 = boxB[1], bx2 = boxB[2], by2 = boxB[3];

            float interX1 = Math.Max(ax1, bx1);
            float interY1 = Math.Max(ay1, by1);
            float interX2 = Math.Min(ax2, bx2);
            float interY2 = Math.Min(ay2, by2);
            float interW = Math.Max(0, interX2 - interX1);
            float interH = Math.Max(0, interY2 - interY1);
            float interArea = interW * interH;

            float areaA = (ax2 - ax1) * (ay2 - ay1);
            float areaB = (bx2 - bx1) * (by2 - by1);
            float union = areaA + areaB - interArea;
            if (union <= 1e-6f) return 0;
            return interArea / union;
        }

        private List<float[]> Nms(List<float[]> boxes, float iouThresh)
        {
            var sorted = boxes.OrderByDescending(b => b[4]).ToList();
            var keep = new List<float[]>();
            while (sorted.Count > 0)
            {
                var current = sorted[0];
                keep.Add(current);
                sorted.RemoveAt(0);
                var remaining = new List<float[]>();
                foreach (var b in sorted)
                {
                    if (ComputeIoU(current, b) <= iouThresh)
                        remaining.Add(b);
                }
                sorted = remaining;
            }
            return keep;
        }

        private float[] RestoreBox(float[] box, float ratio, int left, int top)
        {
            float[] restored = new float[box.Length];
            Array.Copy(box, restored, box.Length);
            int[] xIndices = { 0, 2, 5, 7, 9, 11 };
            int[] yIndices = { 1, 3, 6, 8, 10, 12 };
            foreach (int idx in xIndices)
                restored[idx] = (restored[idx] - left) / ratio;
            foreach (int idx in yIndices)
                restored[idx] = (restored[idx] - top) / ratio;
            return restored;
        }

        private Point2f[] OrderPoints(Point2f[] pts)
        {
            if (pts.Length != 4)
                throw new ArgumentException("需要四个点");
            Point2f[] rect = new Point2f[4];
            float[] sums = pts.Select(p => p.X + p.Y).ToArray();
            float[] diffs = pts.Select(p => p.Y - p.X).ToArray();

            rect[0] = pts[Array.IndexOf(sums, sums.Min())];
            rect[2] = pts[Array.IndexOf(sums, sums.Max())];
            rect[1] = pts[Array.IndexOf(diffs, diffs.Min())];
            rect[3] = pts[Array.IndexOf(diffs, diffs.Max())];
            return rect;
        }

        private Mat FourPointTransform(Mat image, Point2f[] pts)
        {
            Point2f[] rect = OrderPoints(pts);
            Point2f tl = rect[0], tr = rect[1], br = rect[2], bl = rect[3];

            float widthA = (float)Math.Sqrt(Math.Pow(br.X - bl.X, 2) + Math.Pow(br.Y - bl.Y, 2));
            float widthB = (float)Math.Sqrt(Math.Pow(tr.X - tl.X, 2) + Math.Pow(tr.Y - tl.Y, 2));
            int maxWidth = Math.Max((int)widthA, (int)widthB);
            if (maxWidth < 1) maxWidth = 1;

            float heightA = (float)Math.Sqrt(Math.Pow(tr.X - br.X, 2) + Math.Pow(tr.Y - br.Y, 2));
            float heightB = (float)Math.Sqrt(Math.Pow(tl.X - bl.X, 2) + Math.Pow(tl.Y - bl.Y, 2));
            int maxHeight = Math.Max((int)heightA, (int)heightB);
            if (maxHeight < 1) maxHeight = 1;

            Point2f[] dst = new Point2f[]
            {
                new Point2f(0, 0),
                new Point2f(maxWidth - 1, 0),
                new Point2f(maxWidth - 1, maxHeight - 1),
                new Point2f(0, maxHeight - 1)
            };

            Mat matrix = Cv2.GetPerspectiveTransform(rect, dst);
            Mat warped = new Mat();
            Cv2.WarpPerspective(image, warped, matrix, new OpenCvSharp.Size(maxWidth, maxHeight));
            return warped;
        }

        private Mat SplitMerge(Mat img)
        {
            int height = img.Height;
            int width = img.Width;
            int upperHeight = (int)(5f / 12f * height);
            int lowerStart = (int)(1f / 3f * height);
            if (upperHeight <= 0 || lowerStart >= height) return img.Clone();

            Mat upper = img[0, upperHeight, 0, width];
            Mat lower = img[lowerStart, height, 0, width];
            if (lower.Empty() || upper.Empty()) return img.Clone();

            Mat upperResized = upper.Resize(new OpenCvSharp.Size(lower.Width, lower.Height));
            Mat merged = new Mat();
            Cv2.HConcat(new Mat[] { upperResized, lower }, merged);
            upper.Dispose(); lower.Dispose(); upperResized.Dispose();
            return merged;
        }

        private string RecognizeText(Mat roiImg)
        {
            Mat resized = roiImg.Resize(new OpenCvSharp.Size(168, 48));
            Mat floatImg = new Mat();
            resized.ConvertTo(floatImg, MatType.CV_32FC3, 1.0 / 255.0);
            floatImg = (floatImg - MEAN_VALUE) / STD_VALUE;

            int h = 48, w = 168;
            var inputTensor = new DenseTensor<float>(new[] { 1, 3, h, w });
            for (int y = 0; y < h; y++)
            {
                for (int x = 0; x < w; x++)
                {
                    Vec3f vec = floatImg.At<Vec3f>(y, x);
                    // 识别模型输入通道顺序与训练一致,此处保持 BGR
                    inputTensor[0, 0, y, x] = vec.Item0; // B
                    inputTensor[0, 1, y, x] = vec.Item1; // G
                    inputTensor[0, 2, y, x] = vec.Item2; // R
                }
            }
            floatImg.Dispose();
            resized.Dispose();

            var container = new List<NamedOnnxValue>
            {
                NamedOnnxValue.CreateFromTensor("images", inputTensor)
            };
            using (var results = session_rec.Run(container))
            {
                var output = results.First().AsTensor<float>();
                int seqLen = output.Dimensions[1];
                int numClasses = output.Dimensions[2];
                int[] predIndices = new int[seqLen];
                for (int i = 0; i < seqLen; i++)
                {
                    int maxIdx = 0;
                    float maxVal = output[0, i, 0];
                    for (int c = 1; c < numClasses; c++)
                    {
                        float val = output[0, i, c];
                        if (val > maxVal)
                        {
                            maxVal = val;
                            maxIdx = c;
                        }
                    }
                    predIndices[i] = maxIdx;
                }
                return DecodePlate(predIndices);
            }
        }

        private string DecodePlate(int[] preds)
        {
            int previous = 0;
            StringBuilder sb = new StringBuilder();
            foreach (int idx in preds)
            {
                if (idx != 0 && idx != previous && idx < PLATE_CHARS.Length)
                {
                    sb.Append(PLATE_CHARS[idx]);
                }
                previous = idx;
            }
            return sb.ToString();
        }

        private class PlateResult
        {
            public float X1, Y1, X2, Y2;
            public float Score;
            public string Plate;
            public string Type;
        }

        private void button3_Click(object sender, EventArgs e)
        {
            if (pictureBox2.Image == null)
                return;
            Bitmap output = new Bitmap(pictureBox2.Image);
            SaveFileDialog sdf = new SaveFileDialog();
            sdf.Title"保存";
            sdf.Filter"Images (*.jpg)|*.jpg|Images (*.png)|*.png|Images (*.bmp)|*.bmp|Images (*.emf)|*.emf|Images (*.exif)|*.exif|Images (*.gif)|*.gif|Images (*.ico)|*.ico|Images (*.tiff)|*.tiff|Images (*.wmf)|*.wmf";
            if (sdf.ShowDialog() == DialogResult.OK)
            {
                switch (sdf.FilterIndex)
                {
                    case 1: output.Save(sdf.FileName, ImageFormat.Jpeg); break;
                    case 2: output.Save(sdf.FileName, ImageFormat.Png); break;
                    case 3: output.Save(sdf.FileName, ImageFormat.Bmp); break;
                    case 4: output.Save(sdf.FileName, ImageFormat.Emf); break;
                    case 5: output.Save(sdf.FileName, ImageFormat.Exif); break;
                    case 6: output.Save(sdf.FileName, ImageFormat.Gif); break;
                    case 7: output.Save(sdf.FileName, ImageFormat.Icon); break;
                    case 8: output.Save(sdf.FileName, ImageFormat.Tiff); break;
                    case 9: output.Save(sdf.FileName, ImageFormat.Wmf); break;
                }
                MessageBox.Show("保存成功,位置:" + sdf.FileName);
            }
            output.Dispose();
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            startupPath = System.Windows.Forms.Application.StartupPath;
            detect_model_path = System.IO.Path.Combine(startupPath, "model""car_plate_detect.onnx");
            rec_model_path = System.IO.Path.Combine(startupPath, "model""plate_rec.onnx");

            // MP初始化 ONNX 会话
            SessionOptions options = new SessionOptions();
            options.LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_INFO;
            options.AppendExecutionProvider_CPU(0);
            session_detect = new InferenceSession(detect_model_path, options);
            session_rec = new InferenceSession(rec_model_path, new SessionOptions());

            // 设置 textBox1 可显示多行
            textBox1.Multilinetrue;
            textBox1.ScrollBars = ScrollBars.Vertical;

            // 默认加载测试图片(若存在)
            image_path = System.IO.Path.Combine(startupPath, "test_img""0.jpg");
            if (File.Exists(image_path))
            {
                pictureBox1.Image = new Bitmap(image_path);
                image = new Mat(image_path);
            }
        }

        private void pictureBox1_DoubleClick(object sender, EventArgs e)
        {
            if (pictureBox1.Image != null)
                Common.ShowNormalImg(pictureBox1.Image);
        }

        private void pictureBox2_DoubleClick(object sender, EventArgs e)
        {
            if (pictureBox2.Image != null)
                Common.ShowNormalImg(pictureBox2.Image);
        }
    }
}