在新应用的代码部分,和我们在手写数字识别课程介绍的代码比起来,差别最大的地方就在于如何处理输入。在上个案例中,我们只需要简单地将正方形区域中的图像格式调整一下,即可用作MNIST模型的输入。而在本文的案例中,我们必须先对笔画进行分割处理。分割笔画之后我们再将每一个笔画组合转换成MNIST模型所需的单个输入。
新应用需要响应的界面事件,还是和之前一致:需要响应鼠标的按下、移动和抬起三类事件。我们对其中按下和移动的响应事件的修改比较简单,我们只需要在这些响应时间里对新写下的笔画做记录就好了。
首先我们为窗体类新增一个List
类型的字段,用于记录每次鼠标按下、抬起之间鼠标移动过的点,将这些点按顺序连接起来就形成了一道笔画。我们在鼠标按下事件里清空以前记录的所有鼠标移动点,以便记录这次书写产生的新一动点;并在鼠标抬起事件里将这些点转换成笔画对应的数据结构StrokeRecord
(定义见后文)。同样的,我们也为窗体类新增一个List
类型的字段,用于记录已经写下的所有笔画。
private ListstrokePoints = new List (); private List allStrokes = new List ();
在writeArea_MouseDown
方法中新增以下语句用于清空以前记录的鼠标移动点:
strokePoints.Clear();
并在writeArea_MouseMove
方法中记录鼠标这次移动所到达的点:
strokePoints.Add(e.Location);
在writeArea_MouseUp
方法里将这次鼠标按下、抬起之间产生的所有点转换成笔画对应的数据结构。并且因为如果鼠标在抬起之前并没有移动,就不会有点被记录,在这之前我们还通过strokePoints.Any()
先判断一下是否有点被记录。下面是转化移动点的代码:
var thisStrokeRecord = new StrokeRecord(strokePoints); allStrokes.Add(thisStrokeRecord);
包括构造函数在内的StrokeRecord结构定义如下:
////// 用于记录历史笔画信息的数据结构。 /// class StrokeRecord { public StrokeRecord(ListstrokePoints) { // 拷贝所有Point以避免列表在外部被修改。 Points = new List (strokePoints); HorizontalStart = Points.Min(pt => pt.X); HorizontalEnd = Points.Max(pt => pt.X); HorizontalLength = HorizontalEnd - HorizontalStart; OverlayMaxStart = HorizontalStart + (int)(HorizontalLength * (1 - ProjectionOverlayRatioThreshold)); OverlayMinEnd = HorizontalStart + (int)(HorizontalLength * ProjectionOverlayRatioThreshold); } /// /// 构成这一笔画的点。 /// public ListPoints { get; } /// /// 这一笔画在水平方向上的起点。 /// public int HorizontalStart { get; } ////// 这一笔画在水平方向上的终点。 /// public int HorizontalEnd { get; } ////// 这一笔画在水平方向上的长度。 /// public int HorizontalLength { get; } ////// 另一笔画必须越过这些阈值点,才被认为和这一笔画重合。 /// public int OverlayMaxStart { get; } public int OverlayMinEnd { get; } private bool CheckPosition(StrokeRecord other) { return (other.HorizontalStart < OverlayMaxStart) || (OverlayMinEnd < other.HorizontalEnd); } ////// 检查另一笔画是否和这一笔画重叠。 /// /// public bool OverlayWith(StrokeRecord other) { return this.CheckPosition(other) || other.CheckPosition(this); } }
在将新产生的笔画添加到所有笔画的列表中之后,我们就有了当前用户写下的所有笔画了,接下来我们要对这些笔画进行分组。
本文在这里对上文所述的“快速”分割的实现非常简单。在按笔画在水平方向上最左端的坐标,将笔画有小到大排序后,我们从最左边开始扫描所有笔画。如果一个笔画还没有分组,我们就为它指定唯一分组编号,然后再看其右侧有哪些笔画和当前笔画在水平方向上的投影是有效重合的(如上文所述,此处有阈值10%),并将这些重合的笔画定为属于同一组。直到所有笔画都被扫描。
allStrokes = allStrokes.OrderBy(s => s.HorizontalStart).ToList(); int[] strokeGroupIds = new int[allStrokes.Count]; int nextGroupId = 1; for (int i = 0; i < allStrokes.Count; i++) { // 为了避免水平方向太多笔画被连在一起,我们采取一种简单的办法: // 当1、2笔画重叠时,我们就不会在检查笔画2和更右侧笔画是否重叠。 if (strokeGroupIds[i] != 0) { continue; } strokeGroupIds[i] = nextGroupId; nextGroupId++; var s1 = allStrokes[i]; for (int j = 1; i + j < allStrokes.Count; j++) { var s2 = allStrokes[i + j]; if (s2.HorizontalStart < s1.OverlayMaxStart) // 先判断临界条件(阈值10%) { if (strokeGroupIds[i + j] == 0) { if (s1.OverlayWith(s2)) // 在考虑阈值的条件下做完整地判断重合 { strokeGroupIds[i + j] = strokeGroupIds[i]; } } } else { break; } } }
之后即可按对应的分组编号将笔画归组:
List> groups = allStrokes .Zip(strokeGroupIds, Tuple.Create) .GroupBy(tuple => tuple.Item2, tuple => tuple.Item1) // Item2是分组编号, Item1是StrokeRecord .ToList();
小提示
为了方便理解笔画的分割效果,应用界面上预留了“显示笔画分组”的开关。勾选之后写下的笔画会像上文那样被不同的颜色标记出其所在的分组。
分割完成后,我们得到了一个数组groups
,它的每个元素都是一个分组,包括了分组编号和组内的所有笔画。这里我们得到的每一个分组都对应着一个字符。如果分组里有多个笔画,那么这些笔画就是这个字符的组成部分(想象加号和乘号,它们都需要两笔才能写成)。我们可以想到,这个数组groups
里的元素的顺序是很重要的,因为我们要保证最终识别出的表达式里的字符的顺序,才能正确地计算表达式。
我们在循环中顺序访问groups
的每个元素。命名循环变量为group
:
foreach (IGroupinggroup in groups)
循环变量group
的类型是IGrouping
,它代表着一个分组,包括分组的编号(一个整数)和其中的元素(元素都是StrokeRecord
)。IGrouping
泛型接口同时也是一个可迭代的IEnumerable
泛型接口,所以我们可以把group
变量直接当做IEnumerable
类型的对象来使用。
然后我们需要确定这个分组(即其中所有笔画组合成的图形)的位置区域,其中我们最关心水平方向上最左端、最右端的坐标(水平方向的坐标轴是从左向右的)。
通过这两个坐标我们就能确定该分组在水平方向上的投影的长度。我们计算这个长度的目的,是为了在我们为每个分组生成单一位图时,尽量将这个分组的图形放置在单一位图的中间位置。虽然我们还是先创建一个大尺寸的正方形位图(边长为绘图区高度),但是分割后的图形在这个正方形区域上不再具有天然的位置。下面的代码进行了这些位置的计算,和居中该分组所需的水平方向的偏移量的计算:
var groupedStrokes = group.ToList(); // IGrouping本质上也是一个可迭代的IEnumerable // 确定整个分组的所有笔画的范围。 int grpHorizontalStart = groupedStrokes.Min(s => s.HorizontalStart); int grpHorizontalEnd = groupedStrokes.Max(s => s.HorizontalEnd); int grpHorizontalLength = grpHorizontalEnd - grpHorizontalStart; int canvasEdgeLen = writeArea.Height; Bitmap canvas = new Bitmap(canvasEdgeLen, canvasEdgeLen); Graphics canvasGraphics = Graphics.FromImage(canvas); canvasGraphics.Clear(Color.White); // 因为我们提取了每个笔画,就不能把长方形的绘图区直接当做输入了。 // 这里我们把宽度小于 writeArea.Height 的分组在 canvas 内居中。 int halfOffsetX = Math.Max(canvasEdgeLen - grpHorizontalLength, 0) / 2;
之后我们就在新创建出的位图上绘制当前分组内的笔画了(通过canvasGraphics
对象进行绘制):
foreach (var stroke in groupedStrokes) { Point startPoint = stroke.Points[0]; foreach (var point in stroke.Points.Skip(1)) { var from = startPoint; var to = point; // 因为每个分组都是在长方形的绘图区被记录的,所以在单一位图上,需要先减去相对于长方形绘图区的偏移量 grpHorizontalStart from.X = from.X - grpHorizontalStart + halfOffsetX; to.X = to.X - grpHorizontalStart + halfOffsetX; canvasGraphics.DrawLine(penStyle, from, to); startPoint = point; } }
在新应用中,我们一次需要识别多个字符。而以前我们一次只需要识别一个字符,哪怕我们每次都为了识别一个字符调用了一次模型的推理方法(model.Infer(...)
)。
不过我们现在已经准备好了多组数据,这使得我们有机会利用底层AI框架的并行处理能力,来加速我们的推理过程,还省去了手动处理多线程的麻烦。在这里我们采用Visual Studio Tools for AI提供的批量推理功能,一次对所有数据进行推理并得到全部结果。
首先我们在为所得分组创建位图之前,需要先创建一个用于储存所有数据的动态数组:
var batchInferInput = new List>();
在处理所有分组的循环内部,处理完每个分组后,我们需要将该分组对应的像素数据暂时存放在动态数组batchInferInput
中:
// 1. 将分割出的笔画图片缩小至 28 x 28,与训练数据格式一致。 Bitmap clonedBmp = new Bitmap(canvas, ImageSize, ImageSize); var image = new List(ImageSize * ImageSize); for (var x = 0; x < ImageSize; x++) { for (var y = 0; y < ImageSize; y++) { var color = clonedBmp.GetPixel(y, x); image.Add((float)(0.5 - (color.R + color.G + color.B) / (3.0 * 255))); } } // 将这一组笔画对应的矩阵保存下来,以备批量推理。 batchInferInput.Add(image);
可以看到我们对每个分组的处理,都和以前对整个正方形绘图区的像素的处理,是完全一致的。唯一的不同是在以前的应用代码中,List
类型的数组(在上文中为batchInferInput
变量)仅有一个元素,就是唯一一张位图的像素数据。而在本文中这个数组可能有很多元素,每个元素都是一组位图数据。对这样的位图数据集合进行批量推理后,得到的结果(即inferResult
变量)是一个可枚举的类型,我们叫它“第一层枚举”。第一层枚举得到的每个元素也是一个可枚举类型,我们叫它“第二层枚举”。
第一层枚举中的每个元素都对应着一组位图数据的推理结果。同时第一层枚举也是对应着批量推理的输入数组,枚举的结果总数和输入数组的长度相同。对于第二层枚举,由于我们的推理结果只是一个整数,所以第二层枚举总是只有一个元素。我们可以通过.First()
将其取出。这里我们可以看到,在以前的应用代码里,我们通过inferResult.First().First()
取出了唯一的结果,而在这里我们则需要考虑批量推理结果的二维结构。
进行推理的代码如下:
// 2. 进行批量推理 // batchInferInput 是一个列表,它的每个元素都是一次推量的输入。 IEnumerable> inferResult = model.Infer(batchInferInput); // 推量的结果是一个可枚举对象,它的每个元素代表了批量推理中一次推理的结果。我们用 仅一次.First() 将它们的结果都取出来,并格式化。 outputText.Text = string.Join("", inferResult.Select(singleResult => singleResult.First().ToString()));
至此,我们对于多个手写字符的识别就完成了。我们已经得到了可以表示用户手写图形的、易于计算机程序处理的字符串。接下来我们开始对字符串记载的数学表达式进行计算。
本文需要计算的数学表达式的格式,由上文的数据准备和模型训练部分可知,是相对简单的。其中只涉及数字0-9、加减乘除和小括号。对这样的表达式进行求值,是一种非常典型的问题。因为这样的数学表达式有非常清晰、确定的语法规则,对其最直观的处理方法,就是先根据其语法进行解析,构造语法树后进行求值即可。或者,因为这种问题非常经典,我们也可以寻找已有的组件来解决这个问题。
本文直接复用System.Data.DataTable
类提供的Compute
方法来进行表达式的计算。这个方法完全支持本文案例中出现的表达式语法。
因为表达式的计算这部分逻辑边界非常清晰,我们引入一个独立的方法来获取最后的结果:
string EvaluateAndFormatExpression(ListrecognizedLabels)
EvaluateAndFormatExpression
方法接受一个标签序列,其中我们仍在用整数10-15来表示各种数学符号。在这个方法内我们对字符标签做两种映射,分别将标签序列转换成用于输入到计算器进行求值的,和用于在用户界面上展示的。EvaluateAndFormatExpression
方法的返回结果形如“(3+2)÷2=2.5”。其中各种符号皆采用传统的数学写法。该方法的实现如下:
private string EvaluateAndFormatExpression(ListrecognizedLabels) { string[] operatorsToEval = { "+", "-", "*", "/", "(", ")" }; string[] operatorsToDisplay = { "+", "-", "×", "÷", "(", ")" }; string toEval = string.Join("", recognizedLabels.Select(label => { if (0 <= label && label <= 9) { return label.ToString(); } return operatorsToEval[label - 10]; })); var evalResult = new DataTable().Compute(toEval, null); if (evalResult is DBNull) { return "Error"; } else { string toDisplay = string.Join("", recognizedLabels.Select(label => { if (0 <= label && label <= 9) { return label.ToString(); } return operatorsToDisplay[label - 10]; })); return $"{toDisplay}={evalResult}"; } }
同时需要注意的是,根据表达式求值方案的不同,我们可能需要对表达式中的字符进行对应的调整。比如当我们希望在用户界面上将除号显示为更可读的“÷”时,我们采用的求值方案可能并不支持这种除号,而只支持C#语言中的除号/
。那么我们在将识别出的结果输入到表达式计算器中之前,还需要对识别的结果进行合适的映射。
经过一番扩展,我们的新应用已经具备一些不错的功能,初步满足了现实规格的应用需求。从本文的案例中,我们也能得到关于如何将人工智能和传统的技术手段融合起来,帮助我们更好地解决问题的一些启示。当然,这款新应用仍然不够强大和健壮。本文在此列举一些实际场景中的典型问题,这些问题既包括本文案例中没有非常完善地解决的一些问题,也包括“识别手写表达式”这一应用主题在现实中面临的一些主要问题。解决这些问题,正是人工智能技术赋能我们的具体表现,也是“识别手写表达式”这一应用主题更多的价值之所在。
现实生活中大家遇到的数学表达式无疑要比本文目标的表达式格式要复杂的多。这一复杂性不仅体现在数学表达式所包含的字符、元素之多,还包括数学的——特别是手写时的——表达方法里存在的复杂问题。这些问题通常包括:
二维结构是数学表达式里常见的情形,比如上下结构的分数形式、存在大小关系的指数形式、呈包围状的平方根符号、矩阵等等。要正确地处理这些二维结构,我们在识别过程中,必须要准确地识别每种结构所关联的范围,否则哪怕极小的偏差也会对整个表达式造成差之千里的误导。对于这些二维结构,本文介绍的应用还不能处理,因此其应用场景还比较有限。
为了正确地处理这些结构相关的问题,我们可能需要更多模型来识别图形结构上的关系。另外,由于数学的表达方法有一些基本的规则需要遵循,在这些识别过程中如果能融合基于规则的判别手段,有时甚至可以达到更好的效果和更简单的实现。
在成功识别并生成了易于计算机程序处理的数据结构之后,我们还需要保证这些算式在数学层面得到正确的、满足用户期望的计算。比如按照用户需求,我们不能将结果用小数形式表示而应使用分数形式,或是需要保留结果为无理数的一些开平方运算(如
)等等。对于这些多样的数学计算,一些现有的工具,如上文提到的System.Data.DataTable
类,可能就不足以满足我们的需求了。
不过这类问题是比较典型的代数和符号计算。与机器学习和人工智能有所不同,这类问题往往有清晰、具体、可读的定义和结构。我们从这些角度出发,还是能得出较为有效的解决方案。
我们从MNIST数据集的规模就可以看出,哪怕只是单纯的数字0-9,不同人的手写风格也是千差万别的。如果我们要在手写输入的领域创造价值,这一客观因素是不能不考虑的。
对于我们“识别手写数学表达式”这一应用主题来说,相较于只针对单个手写数字的MNIST数据集,在手写风格的问题上我们面临更加广阔的挑战。上文已经就一部分这类问题进行了介绍,比如笔画相互重叠的问题。我们用所谓的“快速”分割一定程度上处理这个问题。但在多种多样的手写风格里,多个数字、字符之间的重叠情形并不少见,甚至还会有连笔(一笔写出多个数字)、笔画断开、噪点、涂改等问题。并且上文所用方法所依赖的笔画的动态信息也可能不存在,比如我们需要处理静态的图片时。
对这类实际问题,我们可以采用图像分割、聚类或目标检测等技术来对笔画进行分割和分组。
不过需要注意的是,在实际的数学表达式中,上述诸多问题往往是相互融合的、难以分割的。如何综合解决这些问题,也是计算机处理复杂手写数学表达式所面临的难题。
除了上述的一些问题外,对于一款计算器产品,我们还有许多额外的期望,比如我们希望能对输入错误的内容或矫正,对历史结果进行存储等等。相对于借力人工智能,我们有很多经典的技术手段可以实现这些传统的功能。
不过由于我们采用了智能的手写输入方式,这些在传统的计算器产品上常见的一些需求,我们可以考虑将其变得更加人性化。比如对于使用电磁笔在屏幕上书写这一使用场景,我们可以提供通过用笔涂改来进行矫正的功能,这就要求应用能识别特性的涂改图形,并对相应区域的输入数据进行调整;对历史结果的储存,我们可以借鉴更多的数学书写方式,通过支持代数方式来储存历史或局部数据,如通过手写p=3.14
和2xpx5
来完成圆周长的计算(需要注意乘号和变量名x
的区别)。
这是一种非常容易出现的情况。因为在手写时,正反小括号和数字1极易混淆。这一问题有时会在扩展数据中体现。我们观察到原始MNIST数据集中(参见上文的数据可视化),很多数字1的形状和弯曲程度已经和括号相近。如果我们在扩展数据部分不做明显的区分,并且我们采用的卷积神经网络对这样微小的数据差别不敏感的话,就会导致造型相近的字符被错误识别的情况。
同理,这样的问题还可能发生在加号和乘号之间。因为加号和乘号的形状基本完全一样,只是靠角度得以区分。如果我们搜集的扩展数据里,这两种符号各自都具有一定的旋转角度,以致角度区分不够明显,这也会导致模型对其识别能力不强的情况出现。