AI應用開發實戰 - 手寫識別應用入門
手寫體識別的應用已經非常流行了,如輸入法,圖片中的文字識別等。但對於大多數開發人員來說,如何實現這樣的一個應用,還是會感覺無從下手。本文從簡單的MNIST訓練出來的模型開始,和大家一起入門手寫體識別。
在本教程結束后,會得到一個能用的AI應用,也許是你的第一個AI應用。雖然離實際使用還有較大的距離(具體差距在文章后面會分析),但會讓你對AI應用有一個初步的認識,有能力逐步搭建出能夠實際應用的模型。
建議和反饋,請發送到
https://github.com/Microsoft/vs-tools-for-ai/issues
聯系我們
OpenmindChina@microsoft.com
准備工作
- 使用win10 64位操作系統的計算機
- 參考上一篇博客AI應用開發實戰 - 從零開始配置環境。在電腦上訓練並導出MNIST模型。
一、 思路
通過上一篇文章搭建環境的介紹后,就能得到一個能識別單個手寫數字的模型了,並且識別的准確度會在98%,甚至99%以上了。那么我們要怎么使用這個模型來搭建應用呢?
大致的步驟如下:
- 實現簡單的界面,將用戶用鼠標或者觸屏的
輸入變成圖片
。 - 將生成的模型
包裝
起來,成為有公開數據接口的類。 - 將輸入的圖片進行
規范化
,成為數據接口能夠使用的格式。 - 最后通過模型來推理(inference)出圖片應該是哪個數字,並顯示出來。
是不是很簡單?
二、動手
步驟一:獲取手寫的數字
提問:那我們要怎么獲取手寫的數字呢?
回答:我們可以寫一個簡單的WinForm畫圖程序,讓我們可以用鼠標手寫數字,然后把圖片保存下來。
首先,我們打開Visual Studio,選擇文件->新建->項目
。

在彈出的窗口里選擇Visual C#->Windows窗體應用
,項目名稱不妨叫做DrawDigit
,解決方案名稱不妨叫做MnistForm
,點擊確定。

此時,Visual Studio也自動彈出了一個窗口的設計圖。

在DrawDigit項目上點擊右鍵,選擇屬性,在生成一欄將平台目標從Any CPU
改為x64
。

否則,DrawDigit(首選32位)與它引用的MnistForm(64位)的編譯平台不一致會引發System.BadImageFormatException
的異常。
然后我們對這個窗口做一些簡單的修改:
首先我們打開VS窗口左側的工具箱,這個窗口程序需要以下三種組件:
- PictureBox:用來手寫數字,並且把數字保存成圖片
- Label:用來顯示模型的識別結果
- Button:用來清理PictureBox的手寫結果
那經過一些簡單的選擇與拖動還有調整大小
,這個窗口現在是這樣的:

一些注意事項
- 這些組件都可以通過
右鍵->查看屬性
,在屬性里修改它們的設置 - 為了方便把PictureBox里的圖片轉化成Mnist能識別的格式,PictureBox的需要是正方形
- 可以給這些控件起上有意義的名稱。
- 可以調整一下label控件大小、字體等,讓它更美觀。
經過一些簡單的調整,這個窗口現在是這樣的:

現在來讓我們愉快地給這些組件添加事件!
還是在屬性窗口,我們選擇某個組件,右鍵->查看屬性,點擊閃電符號,給組件綁定對應的事件。每次綁定后,會跳到代碼部分,生成一個空函數。點回設計視圖繼續操作即可。
組件類型 | 事件 |
---|---|
pictureBox1 | 在Mouse 下雙擊MouseDown 、MouseUp 、MouseMove 來生成對應的響應事件函數。 |
button1 | 如上,在Action 下雙擊Click 。 |
Form1 | 如上,在Behavior 下雙擊Load 。 |
然后我們開始補全對應的函數體內容。
注意,如果在上面改變了控件的名稱,下面的代碼需要做對應的更改。
廢話少說上代碼!
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Drawing.Drawing2D;//用於優化繪制的結果
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using MnistModel;
namespace DrawDigit
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private Bitmap digitImage;//用來保存手寫數字
private Point startPoint;//用於繪制線段,作為線段的初始端點坐標
private Mnist model;//用於識別手寫數字
private const int MnistImageSize = 28;//Mnist模型所需的輸入圖片大小
private void Form1_Load(object sender, EventArgs e)
{
//當窗口加載時,繪制一個白色方框
model = new Mnist();
digitImage = new Bitmap(pictureBox1.Width, pictureBox1.Height);
Graphics g = Graphics.FromImage(digitImage);
g.Clear(Color.White);
pictureBox1.Image = digitImage;
}
private void clean_click(object sender, EventArgs e)
{
//當點擊清除時,重新繪制一個白色方框,同時清除label1顯示的文本
digitImage = new Bitmap(pictureBox1.Width, pictureBox1.Height);
Graphics g = Graphics.FromImage(digitImage);
g.Clear(Color.White);
pictureBox1.Image = digitImage;
label1.Text = "";
}
private void pictureBox1_MouseDown(object sender, MouseEventArgs e)
{
//當鼠標左鍵被按下時,設置isPainting為true,並記錄下需要繪制的線段的起始坐標
startPoint = (e.Button == MouseButtons.Left) ? e.Location : startPoint;
}
private void pictureBox1_MouseMove(object sender, MouseEventArgs e)
{
//當鼠標在移動,且當前處於繪制狀態時,根據鼠標的實時位置與記錄的起始坐標繪制線段,同時更新需要繪制的線段的起始坐標
if (e.Button == MouseButtons.Left)
{
Graphics g = Graphics.FromImage(digitImage);
Pen myPen = new Pen(Color.Black, 40);
myPen.StartCap = LineCap.Round;
myPen.EndCap = LineCap.Round;
g.DrawLine(myPen,startPoint, e.Location);
pictureBox1.Image = digitImage;
g.Dispose();
startPoint = e.Location;
}
}
private void pictureBox1_MouseUp(object sender, MouseEventArgs e)
{
//當鼠標左鍵釋放時
//同時開始處理圖片進行推理
//暫時不處理這里的代碼
}
}
}
步驟二:把模型包裝成一個類
將模型包裝成一個C#是整個過程中比較麻煩的一步。所幸的是,Tools for AI對此提供了很好的支持。進一步了解,可以看這里。
首先,我們在解決方案MnistForm下點擊鼠標右鍵,選擇添加->新建項目
,在彈出的窗口里選擇AI Tools->Inference->模型推理類庫
,名稱不妨叫做MnistModel
,點擊確定,於是我們又多了一個項目,

然后自己配置好這個項目的名稱、位置,點擊確定
。
然后彈出一個模型推理類庫創建向導,這個時候就需要我們選擇自己之前訓練好的模型了~

首先在模型路徑里選擇保存的模型文件的路徑。這里我們使用在AI應用開發實戰 - 從零開始配置環境博客中訓練並導出的模型
note:模型可在
/samples-for-ai/examples/tensorflow/MNIST
目錄下找到,其中output
文件夾保存了檢查點文件,export
文件夾保存了模型文件。
對於TensorFlow,我們可以選擇檢查點的.meta
文件,或者是保存的模型的.pb
文件
這里我們選擇在AI應用開發實戰 - 從零開始配置環境這篇博客最后生成的export
目錄下的檢查點的SavedModel.pb
文件,這時程序將自動配置好配置推理接口,見下圖:

類名可以自己定義,因為我們用的是MNIST,那么類名就叫Mnist
好了,然后點擊確定。
這樣,在解決方案資源管理器
里,在解決方案MnistForm
下,就多了一個MnistModel
:

雙擊Mnist.cs
,我們可以看到項目自動把模型進行了封裝,生成了一個公開的infer
函數。
然后我們在MnistModel
上右擊,再選擇生成
,等待一會,這個項目就可以使用了~
步驟三:連接兩個部分
這一步差不多就是這么個感覺:
I have an apple , I have a pen. AH~ , Applepen
首先,我們來給DrawDigit添加引用,讓它能使用MnistModel。在DrawDigit項目的引用上點擊鼠標右鍵,點擊添加引用
,在彈出的窗口中選擇MnistModel
,點擊確定。


然后,由於MNIST的模型的輸入是一個28×28的白字黑底的灰度圖,因此我們首先要對圖片進行一些處理。
首先將圖片轉為28×28的大小。
然后將RGB圖片轉化為灰階圖,將灰階標准化到[-0.5,0.5]區間內,轉換為黑底白字。
最后將圖片用mnist模型要求的格式包裝起來,並傳送給它進行推理。
於是,我們在pictureBox1_MouseUp
中添加上這些代碼,並且在文件最初添加上using MnistModel;
:
private void pictureBox1_MouseUp(object sender, MouseEventArgs e)
{
//當鼠標左鍵釋放時
//同時開始處理圖片進行推理
if (e.Button == MouseButtons.Left)
{
// 復制pictureBox中的圖片並縮放到28*28成為新的圖片(tmpBmp)
Bitmap tmpBmp = new Bitmap(digitImage, MinstImageSize, MinstImageSize);
//將圖片轉為灰階圖,並將圖片的像素信息保存在list中
var imageData = new List<float>(MnistImageSize * MnistImageSize);
for (var y = 0; y < MnistImageSize; y++)
{
for (var x = 0; x < MnistImageSize; x++)
{
var color = tmpBmp.GetPixel(x, y);
var pixel = (float)(0.5 - (color.R + color.G + color.B) / (3.0 * 255));
imageData.Add(pixel);
}
}
//將圖片信息包裝為mnist模型規定的輸入格式
var batchData = new List<IEnumerable<float>>();
batchData.Add(imageData);
//將圖片傳送給mnist模型進行推理
var result = model.Infer(batchData);
//將推理結果輸出
label1.Text = result.First().First().ToString();
}
}
最后讓我們嘗試一下運行~
三、效果展示
現在我們就有了一個簡單的小程序,可以識別手寫的數字了。
趕緊試試效果怎么樣~

注意
- 路徑中不能有中文字符,否則可能找不到模型。
擴展
嘗試識別多個數字
我們已經支持了單個手寫數字的識別,那能不能支持多個手寫數字的識別呢?同時寫下多個數字,正是現實中更為常見的情形。相比之下,如果只能一次識別一個手寫數字,應用就會有比較大的局限性。
首先,我們可以嘗試在現有的應用里一次寫下兩個數字,看看識別效果(為了更好的展示效果,將筆畫的寬度由40調整為20。這一改動對單個數字的識別並無大的影響):
識別效果不盡人意。
右上角展示的結果准確地反應了模型對我們手寫輸入的推理結果(即result.First().First().ToString()
),然而這一結果並不像我們期望的那樣是“42”。
了解MNIST數據集的讀者們可能已經意識到了,這是“理所當然”的。歸根結底,這一問題的症結在於:作為我們AI應用核心的AI模型,本身並不具備識別多個數字的能力——當前案例中我們使用的AI模型是基於MNIST數據集訓練的(訓練過程請回顧我們之前的博客AI應用開發實戰 - 從零開始配置環境),而MNIST數據集只覆蓋了單個的手寫數字;並且,我們並未對筆跡圖形作額外的處理。
結果是在寫下多個數字的情況下,我們實際上在“強行”讓AI模型做超出其適應性范圍的判斷。這屬於AI模型的誤用。其結果自然難以令人滿意。
那么,為了增強應用的可用性,我們能不能改善它、讓它能識別多個數字呢?我們很自然地想到,既然MNIST模型已經能很好地識別單個數字,那我們只需要把多個數字分開,一個一個地讓MNIST模型進行識別就好了。這樣,我們就引入了一個新的子問題,即是“多個手寫數字的分割”。
子問題:分割多個手寫數字
我們注意到本文介紹的應用有一個特點,那就是最終用作輸入的圖形,是用戶當場寫下的,而非通過圖片文件導入的靜態圖片,即我們擁有筆畫產生過程中的全部動態信息,比如筆畫的先后順序,筆畫的重疊關系等等。考慮到這些信息,我們可以設計一種基本的分割規則:在水平面上的投影相重疊的筆畫,我們就認為它們同屬於一個數字。
筆畫和水平方向上投影的關系示意如下圖:
因此書寫時,就要求不同的數字之間盡量隔開。當然為了盡可能處理不經意的重疊,我們還可以為重疊部分相對每一筆畫的位置設定一個閾值,如至少進入筆畫一端的10%以內。
應用這樣的規則后,我們就能比較好的把多個手寫數字分割開,並能利用Visual Studio Tools for AI提供的批量推理功能,一次性對所有分割出的圖形做推理。
多個手寫數字識別的最終效果如圖:
當然,我們對問題的定義還是非常理想化,分割算法也比較簡單。在實際應用中,我們還經常要考慮非二值圖形、噪點、非數字的判別等等。並且對手寫數字的分割可能比我們設定的規則要復雜,因為在現實場景中,水平方向上的重疊可能會影響圖形的涵義。
將兩個手寫數字分割開這一問題,實際上和經典的圖像分割問題非常類似。雖然本文示例中的圖像非常簡單,但仍然可能具有相當復雜的語義需要處理。為此,我們可能需要引入更多的模型,或者擴展現有的模型來正確判斷多個圖形之間的關系。
進階
那么,如果要識別多個連寫的數字,或支持字母該怎么做呢?大家多用用也會發現,如果數字寫得很小,或者沒寫到正中,識別起來正確率也會不高。要解決這些問題,做成真正的產品,就不止這一個模型了。比如在多個數字識別中,可能要根據經驗來切分圖,或者訓練另一個模型來檢測並分割數字。要支持字母,則需要重新訓練一個包含手寫字母的模型,並准備更多的字母的數據。要解決字太小的問題,還要檢測一下字的大小,做合適的放大等等。
我們可以看到,一個訓練出來的模型本身到一個實際的應用之間還有不少的功能要實現。希望我們這一系列的介紹,能夠幫助大家將機器學習的概念帶入到傳統的編程領域中,做出更聰明的產品。