网站建设咨询服务,软件开发工时费一般是多少,商丘做网站公司,微信定制v怎么弄理解代码
输入处理
在新应用的代码部分#xff0c;和我们在手写数字识别课程介绍的代码比起来#xff0c;差别最大的地方就在于如何处理输入。在上个案例中#xff0c;我们只需要简单地将正方形区域中的图像格式调整一下#xff0c;即可用作MNIST模型的输入。而在本文的案…
理解代码
输入处理
在新应用的代码部分和我们在手写数字识别课程介绍的代码比起来差别最大的地方就在于如何处理输入。在上个案例中我们只需要简单地将正方形区域中的图像格式调整一下即可用作MNIST模型的输入。而在本文的案例中我们必须先对笔画进行分割处理。分割笔画之后我们再将每一个笔画组合转换成MNIST模型所需的单个输入。
新应用需要响应的界面事件还是和之前一致需要响应鼠标的按下、移动和抬起三类事件。我们对其中按下和移动的响应事件的修改比较简单我们只需要在这些响应时间里对新写下的笔画做记录就好了。
记录笔画的产生过程
首先我们为窗体类新增一个ListPoint类型的字段用于记录每次鼠标按下、抬起之间鼠标移动过的点将这些点按顺序连接起来就形成了一道笔画。我们在鼠标按下事件里清空以前记录的所有鼠标移动点以便记录这次书写产生的新一动点并在鼠标抬起事件里将这些点转换成笔画对应的数据结构StrokeRecord定义见后文。同样的我们也为窗体类新增一个ListStrokeRecord类型的字段用于记录已经写下的所有笔画。 private ListPoint strokePoints new ListPoint();
private ListStrokeRecord allStrokes new ListStrokeRecord(); 在writeArea_MouseDown方法中新增以下语句用于清空以前记录的鼠标移动点 strokePoints.Clear(); 并在writeArea_MouseMove方法中记录鼠标这次移动所到达的点 strokePoints.Add(e.Location); 在writeArea_MouseUp方法里将这次鼠标按下、抬起之间产生的所有点转换成笔画对应的数据结构。并且因为如果鼠标在抬起之前并没有移动就不会有点被记录在这之前我们还通过strokePoints.Any()先判断一下是否有点被记录。下面是转化移动点的代码 var thisStrokeRecord new StrokeRecord(strokePoints);
allStrokes.Add(thisStrokeRecord); 包括构造函数在内的StrokeRecord结构定义如下 /// summary
/// 用于记录历史笔画信息的数据结构。
/// /summary
class StrokeRecord
{public StrokeRecord(ListPoint strokePoints){// 拷贝所有Point以避免列表在外部被修改。Points new ListPoint(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);}/// summary/// 构成这一笔画的点。/// /summarypublic ListPoint Points { get; }/// summary/// 这一笔画在水平方向上的起点。/// /summarypublic int HorizontalStart { get; }/// summary/// 这一笔画在水平方向上的终点。/// /summarypublic int HorizontalEnd { get; }/// summary/// 这一笔画在水平方向上的长度。/// /summarypublic int HorizontalLength { get; }/// summary/// 另一笔画必须越过这些阈值点才被认为和这一笔画重合。/// /summarypublic int OverlayMaxStart { get; }public int OverlayMinEnd { get; }private bool CheckPosition(StrokeRecord other){return (other.HorizontalStart OverlayMaxStart) || (OverlayMinEnd other.HorizontalEnd);}/// summary/// 检查另一笔画是否和这一笔画重叠。/// /summary/// param nameother/parampublic 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;}}
} 之后即可按对应的分组编号将笔画归组 ListIGroupingint, StrokeRecord groups allStrokes.Zip(strokeGroupIds, Tuple.Create).GroupBy(tuple tuple.Item2, tuple tuple.Item1) // Item2是分组编号, Item1是StrokeRecord.ToList(); 小提示 为了方便理解笔画的分割效果应用界面上预留了“显示笔画分组”的开关。勾选之后写下的笔画会像上文那样被不同的颜色标记出其所在的分组。 为每个分组生成单一位图
分割完成后我们得到了一个数组groups它的每个元素都是一个分组包括了分组编号和组内的所有笔画。这里我们得到的每一个分组都对应着一个字符。如果分组里有多个笔画那么这些笔画就是这个字符的组成部分想象加号和乘号它们都需要两笔才能写成。我们可以想到这个数组groups里的元素的顺序是很重要的因为我们要保证最终识别出的表达式里的字符的顺序才能正确地计算表达式。
我们在循环中顺序访问groups的每个元素。命名循环变量为group
foreach (IGroupingint, StrokeRecord group in groups)
循环变量group的类型是IGroupingint, StrokeRecord它代表着一个分组包括分组的编号一个整数和其中的元素元素都是StrokeRecord。IGroupingTKey, TElement泛型接口同时也是一个可迭代的IEnumerableTElement泛型接口所以我们可以把group变量直接当做IEnumerableStrokeRecord类型的对象来使用。
然后我们需要确定这个分组即其中所有笔画组合成的图形的位置区域其中我们最关心水平方向上最左端、最右端的坐标水平方向的坐标轴是从左向右的。
通过这两个坐标我们就能确定该分组在水平方向上的投影的长度。我们计算这个长度的目的是为了在我们为每个分组生成单一位图时尽量将这个分组的图形放置在单一位图的中间位置。虽然我们还是先创建一个大尺寸的正方形位图边长为绘图区高度但是分割后的图形在这个正方形区域上不再具有天然的位置。下面的代码进行了这些位置的计算和居中该分组所需的水平方向的偏移量的计算 var groupedStrokes group.ToList(); // IGroupingTKey, TElement本质上也是一个可迭代的IEnumerableTElement// 确定整个分组的所有笔画的范围。
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;// 因为每个分组都是在长方形的绘图区被记录的所以在单一位图上需要先减去相对于长方形绘图区的偏移量 grpHorizontalStartfrom.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 ListIEnumerablefloat();
在处理所有分组的循环内部处理完每个分组后我们需要将该分组对应的像素数据暂时存放在动态数组batchInferInput中 // 1. 将分割出的笔画图片缩小至 28 x 28与训练数据格式一致。
Bitmap clonedBmp new Bitmap(canvas, ImageSize, ImageSize);var image new Listfloat(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); 可以看到我们对每个分组的处理都和以前对整个正方形绘图区的像素的处理是完全一致的。唯一的不同是在以前的应用代码中ListIEnumerablefloat类型的数组在上文中为batchInferInput变量仅有一个元素就是唯一一张位图的像素数据。而在本文中这个数组可能有很多元素每个元素都是一组位图数据。对这样的位图数据集合进行批量推理后得到的结果即inferResult变量是一个可枚举的类型我们叫它“第一层枚举”。第一层枚举得到的每个元素也是一个可枚举类型我们叫它“第二层枚举”。
第一层枚举中的每个元素都对应着一组位图数据的推理结果。同时第一层枚举也是对应着批量推理的输入数组枚举的结果总数和输入数组的长度相同。对于第二层枚举由于我们的推理结果只是一个整数所以第二层枚举总是只有一个元素。我们可以通过.First()将其取出。这里我们可以看到在以前的应用代码里我们通过inferResult.First().First()取出了唯一的结果而在这里我们则需要考虑批量推理结果的二维结构。
进行推理的代码如下 // 2. 进行批量推理
// batchInferInput 是一个列表它的每个元素都是一次推量的输入。
IEnumerableIEnumerablelong inferResult model.Infer(batchInferInput);// 推量的结果是一个可枚举对象它的每个元素代表了批量推理中一次推理的结果。我们用 仅一次.First() 将它们的结果都取出来并格式化。
outputText.Text string.Join(, inferResult.Select(singleResult singleResult.First().ToString())); 计算表达式
至此我们对于多个手写字符的识别就完成了。我们已经得到了可以表示用户手写图形的、易于计算机程序处理的字符串。接下来我们开始对字符串记载的数学表达式进行计算。
本文需要计算的数学表达式的格式由上文的数据准备和模型训练部分可知是相对简单的。其中只涉及数字0-9、加减乘除和小括号。对这样的表达式进行求值是一种非常典型的问题。因为这样的数学表达式有非常清晰、确定的语法规则对其最直观的处理方法就是先根据其语法进行解析构造语法树后进行求值即可。或者因为这种问题非常经典我们也可以寻找已有的组件来解决这个问题。
本文直接复用System.Data.DataTable类提供的Compute方法来进行表达式的计算。这个方法完全支持本文案例中出现的表达式语法。
因为表达式的计算这部分逻辑边界非常清晰我们引入一个独立的方法来获取最后的结果
string EvaluateAndFormatExpression(Listint recognizedLabels)
EvaluateAndFormatExpression方法接受一个标签序列其中我们仍在用整数10-15来表示各种数学符号。在这个方法内我们对字符标签做两种映射分别将标签序列转换成用于输入到计算器进行求值的和用于在用户界面上展示的。EvaluateAndFormatExpression方法的返回结果形如“(32)÷22.5”。其中各种符号皆采用传统的数学写法。该方法的实现如下 private string EvaluateAndFormatExpression(Listint recognizedLabels)
{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数据集在手写风格的问题上我们面临更加广阔的挑战。上文已经就一部分这类问题进行了介绍比如笔画相互重叠的问题。我们用所谓的“快速”分割一定程度上处理这个问题。但在多种多样的手写风格里多个数字、字符之间的重叠情形并不少见甚至还会有连笔一笔写出多个数字、笔画断开、噪点、涂改等问题。并且上文所用方法所依赖的笔画的动态信息也可能不存在比如我们需要处理静态的图片时。
对这类实际问题我们可以采用图像分割、聚类或目标检测等技术来对笔画进行分割和分组。
不过需要注意的是在实际的数学表达式中上述诸多问题往往是相互融合的、难以分割的。如何综合解决这些问题也是计算机处理复杂手写数学表达式所面临的难题。
更全面的计算功能
除了上述的一些问题外对于一款计算器产品我们还有许多额外的期望比如我们希望能对输入错误的内容或矫正对历史结果进行存储等等。相对于借力人工智能我们有很多经典的技术手段可以实现这些传统的功能。
不过由于我们采用了智能的手写输入方式这些在传统的计算器产品上常见的一些需求我们可以考虑将其变得更加人性化。比如对于使用电磁笔在屏幕上书写这一使用场景我们可以提供通过用笔涂改来进行矫正的功能这就要求应用能识别特性的涂改图形并对相应区域的输入数据进行调整对历史结果的储存我们可以借鉴更多的数学书写方式通过支持代数方式来储存历史或局部数据如通过手写p3.14和2xpx5来完成圆周长的计算需要注意乘号和变量名x的区别。
常见问题
新模型对括号和数字1的识别很差
这是一种非常容易出现的情况。因为在手写时正反小括号和数字1极易混淆。这一问题有时会在扩展数据中体现。我们观察到原始MNIST数据集中参见上文的数据可视化很多数字1的形状和弯曲程度已经和括号相近。如果我们在扩展数据部分不做明显的区分并且我们采用的卷积神经网络对这样微小的数据差别不敏感的话就会导致造型相近的字符被错误识别的情况。
同理这样的问题还可能发生在加号和乘号之间。因为加号和乘号的形状基本完全一样只是靠角度得以区分。如果我们搜集的扩展数据里这两种符号各自都具有一定的旋转角度以致角度区分不够明显这也会导致模型对其识别能力不强的情况出现。