使用C#、WPF与ONNX模型实现手写数字识别

使用 C#、WPF 与 ONNX 模型实现手写数字识别

前言

项目完整代码见 GitHub 仓库: jinggqu/MLNetDemo

本文将介绍如何使用 C#、WPF 与 ONNX 模型实现一个简单的手写数字识别项目。整个流程跑通后,即可应用更加复杂的深度学习模型。

WPF 界面开发

项目的用户界面相对简单,主要包含一个 InkCanvas(Name 设置为 inkCanvas) 用于用户绘制数字,一个识别按钮与一个清除 Canvas 内容的按钮。软件用户界面如下图所示。

WPF 手写数字识别应用界面

项目最终效果图如下图所示。

手写数字识别应用运行效果

WPF 界面采用 xaml 文件进行定义,项目界面源代码详见 GitHub 仓库。

ONNX

ONNX 简介

ONNX 是一种用于表示机器学习模型的开放格式。ONNX 定义了一组通用运算符(机器学习和深度学习模型的构建基块)和通用文件格式,使 AI 开发人员能够使用具有各种框架、工具、运行时和编译器的模型。详见ONNX 官方网站

ONNX 模型库及项目模型选用

ONNX Model Zoo 收录了大量的预训练模型,包括计算机视觉领域常用的目标检测、图像分类及自然语言处理领域的 GPT 模型。处于演示需要,本项目选择 ONNX Model Zoo 中的手写数字识别模型作为实验模型。

查看模型结构

下载 ONNX 模型后,我们还需要找到模型中的输入变量名与输出变量名。因此使用 Netron 来检视模型。Netron 打开 ONNX 模型后的效果如下图所示。

MNIST 手写数字识别 ONNX 模型结构图

从图中右侧可以看到,模型输入变量名为 Input3,数据类型为 float 数组,尺寸为 1×1×28×28。输出变量名为 Plus214_Output_0,数据类型为 float 数组,尺寸为 1×10,即为十分类中的每个类别概率值。

整合模型

Visual Studio 安装深度学习插件

针对不同的平台,可以在 ONNX Runtime 官网选择对应的插件或依赖,详见ONNX Runtime

本项目采用 Visual Studio 2022 开发,按照官方说明需要安装 Microsoft.ML.OnnxRuntime,读者可以在 Visual Studio 中解决方案资源管理器中右键单击解决方案名称,选择管理 NuGet 程序包选项,搜索安装 Microsoft.ML.OnnxRuntime。

除了安装上述插件外,还需要安装微软开发的应用于.Net 平台的机器学习开发包,同时本项目还涉及到图像处理,因此也需要安装图像处理相关的包。故需要安装的所有依赖包如下:

  • Microsoft.ML
  • Microsoft.ML.OnnxRuntime
  • Microsoft.ML.OnnxTransformer
  • System.Drawing.Common

输入输出数据定义

对于输入数据,通过 Netron 得到输入变量名和数据类型后,即可得到如下的输入数据定义。

1
2
3
4
5
6
public class InputData
{
    [VectorType(1 * 1 * 28 * 28)]
    [ColumnName("Input3")]
    public float[] Image { get; set; }
}

其中 VectorType 用于表征数据尺寸,由于本项目不涉及输入批次,仅为单张图片输入模型,因此第一维的 Batch Size 设置为 1,第二维的通道数也设置为 1。因此省略前两个维度,写成 [VectorType(28 * 28)] 也是可以的。ColumnName 需要与上述 Netron 中展示的输入变量名严格对应。

对于输出数据,同上可得到如下的数据定义,不再赘述。

1
2
3
4
5
public class OutputData
{
    [ColumnName("Plus214_Output_0")]
    public float[] Result { get; set; }
}

C## 图像处理

本项目涉及的图像处理总体流程:

  1. 获取 InkCanvas 的内容并转为位图 Bitmap
  2. 将 Bitmap 从原始尺寸变换到到模型输入规定的尺寸
  3. 将变换尺寸后的 Bitmap 转为单通道 8 位灰度图
  4. 将 Bitmap 对象转为 float 一维数组

获取 InkCanvas 的内容并转为位图

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private float[] ConvertInkCanvasToFloatArray()
{
    // 获取InkCanvas的大小
    int width = (int)inkCanvas.ActualWidth;
    int height = (int)inkCanvas.ActualHeight;

    // 创建RenderTargetBitmap
    RenderTargetBitmap rtb = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Default);
    rtb.Render(inkCanvas);

    // 转换为8位灰度图
    FormatConvertedBitmap grayscaleBitmap = new FormatConvertedBitmap();
    grayscaleBitmap.BeginInit();
    grayscaleBitmap.Source = rtb;
    grayscaleBitmap.DestinationFormat = PixelFormats.Gray8; // 8-bit grayscale
    grayscaleBitmap.EndInit();

    // 将WPF的BitmapSource转换为System.Drawing.Bitmap
    Bitmap bitmap;
    using (MemoryStream outStream = new MemoryStream())
    {
        BitmapEncoder enc = new BmpBitmapEncoder();
        enc.Frames.Add(BitmapFrame.Create(grayscaleBitmap));
        enc.Save(outStream);
        bitmap = new Bitmap(outStream);
    }

    // 变换 Bitmap 尺寸
    Bitmap bmp = ReSizeImage(bitmap, inputWidth, inputHeight);

    // 通道变换,获取单通道灰度图
    Bitmap graybmp = GetGaryImage(bmp);

    // 将图像转为一维数组并返回
    return ConvertBitmapToFloatArray(graybmp);
}

变换 Bitmap 尺寸

本项目中画布的尺寸为 336×336,但手写数组识别模型使用的训练数据集为 MNIST,从模型输入数据中可以看到其图像尺寸为 28×28,因此需要变换位图的尺寸。

1
2
3
4
5
6
7
8
9
private static Bitmap ReSizeImage(Image img, int width, int height)
{
    Bitmap bitmap = new Bitmap(width, height);
    Graphics g = Graphics.FromImage(bitmap);
    g.InterpolationMode = InterpolationMode.HighQualityBicubic;
    g.DrawImage(img, 0, 0, bitmap.Width, bitmap.Height);
    g.Dispose();
    return bitmap;
}

通道变换

由于上述过程中生成的图像为 RGB 图像,但本例仅需要单通道灰度图,即与 MNIST 数据集保持一致,因此需要对其进行通道变换。此处选用 GDI+ 的 ColorMatrix 特性实现通道变换,代码参考自ML.NET (。・∀・)ノ 来用 C## 跑机器学习吧!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private static Bitmap GetGaryImage(Bitmap src)
{
    float[][] colorMatrix = {
        new float[] {0.299f, 0.299f, 0.299f,     0,     0},
        new float[] {0.587f, 0.587f, 0.587f,     0,     0},
        new float[] {0.114f, 0.114f, 0.114f,     0,     0},
        new float[] {     0,      0,      0,     1,     0},
        new float[] {     0,      0,      0,     0,     1}
    };

    ImageAttributes ia = new ImageAttributes();
    ColorMatrix cm = new ColorMatrix(colorMatrix);
    ia.SetColorMatrix(cm, ColorMatrixFlag.Default, ColorAdjustType.Bitmap);

    Graphics g = Graphics.FromImage(src);
    g.DrawImage(
        src,
        new Rectangle(0, 0, src.Width, src.Height),
        0, 0,
        src.Width, src.Height,
        GraphicsUnit.Pixel,
        ia
    );
    g.Dispose();

    return src;
}

变换后得到的灰度图如下图所示(28×28,图片显示效果较小)。

28×28 像素的手写数字灰度图

将图像转为一维数组

根据上述分析,输入数据为一维 float 数组,因此还需要将灰度图转换为一维数组。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private float[] ConvertBitmapToFloatArray(Bitmap graybmp)
{
    float[] graydata = new float[inputWidth * inputHeight];
    for (int i = 0; i < inputWidth; i += 1)
    {
        for (int j = 0; j < inputHeight; j += 1)
        {
            System.Drawing.Color rescolor = graybmp.GetPixel(j, i);
            graydata[(i * inputWidth) + j] = rescolor.R / 255.0f;
        }
    }
    return graydata;
}

初始化模型

首先定义两个全局变量 _modelPath_predictionEngine,分别代表 ONNX 的模型存放地址与 TTransformer 模型推理变量。在初始化模型时,加载模型后给模型传入一组空输入参数以创建推理变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private readonly string _modelPath = "../../../assets/mnist.onnx";
private PredictionEngine<InputData, OutputData> _predictionEngine;

private void InitializeModel()
{
    MLContext context = new MLContext();
    var pipeline = context.Transforms.ApplyOnnxModel(_modelPath);

    var emptyData = new List<InputData>();
    var data = context.Data.LoadFromEnumerable(emptyData);
    var model = pipeline.Fit(data);

    _predictionEngine = context.Model.CreatePredictionEngine<InputData, OutputData>(model);
}

清除画布

为了清除画布,需要为 WPF 布局中的 Clear Digit 按钮绑定名为 ClearButtonClick 的事件,事件实现如下。其中,numberLabel为显示识别结果的标签,清除画布时需要同时将其置空。

1
2
3
4
5
private void ClearButtonClick(object sender, RoutedEventArgs e)
{
    inkCanvas.Strokes.Clear();
    numberLabel.Text = "";
}

预测结果

在上述图像处理的基础上,调用模型预测结果之前还需要将图像处理结果传给模型,同时为 recognize 按钮绑定 RecognizeDigit。其中,numberLabel为显示识别结果的标签,因此需要将模型预测结果(1×10 个概率值)中最大的概率值所代表的数字,赋值给 numberLabel

1
2
3
4
5
private void RecognizeDigit(object sender, RoutedEventArgs e)
{
    var result = _predictionEngine.Predict(new InputData() { Image = ConvertInkCanvasToFloatArray() });
    numberLabel.Text = result.Result.ToList().IndexOf((float)result.Result.Max()).ToString();
}

参考文献

  1. ML.NET (。・∀・)ノ 来用 C## 跑机器学习吧!
  2. 使用 ML.Net 轻松接入 AI 模型!
  3. Chat GPT 4
  4. Tutorial: Create a Windows Machine Learning UWP application (C#)
Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy