理解代码
输入处理
在新应用的代码部分,和我们在手写数字识别课程介绍的代码比起来,差别最大的地方就在于如何处理输入。在上个案例中,我们只需要简单地将正方形区域中的图像格式调整一下,即可用作MNIST模型的输入。而在本文的案例中,我们必须先对笔画进行分割处理。分割笔画之后我们再将每一个笔画组合转换成MNIST模型所需的单个输入。
新应用需要响应的界面事件,还是和之前一致:需要响应鼠标的按下、移动和抬起三类事件。我们对其中按下和移动的响应事件的修改比较简单,我们只需要在这些响应时间里对新写下的笔画做记录就好了。
记录笔画的产生过程
首先我们为窗体类新增一个List<Point>
类型的字段,用于记录每次鼠标按下、抬起之间鼠标移动过的点,将这些点按顺序连接起来就形成了一道笔画。我们在鼠标按下事件里清空以前记录的所有鼠标移动点,以便记录这次书写产生的新一动点;并在鼠标抬起事件里将这些点转换成笔画对应的数据结构StrokeRecord
(定义见后文)。同样的,我们也为窗体类新增一个List<StrokeRecord>
类型的字段,用于记录已经写下的所有笔画。
private List<Point> strokePoints = new List<Point>(); private List<StrokeRecord> allStrokes = new List<StrokeRecord>();
在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(List<Point> strokePoints) { // 拷贝所有Point以避免列表在外部被修改。 Points = new List<Point>(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> /// 构成这一笔画的点。 /// </summary> public List<Point> Points { get; } /// <summary> /// 这一笔画在水平方向上的起点。 /// </summary> public int HorizontalStart { get; } /// <summary> /// 这一笔画在水平方向上的终点。 /// </summary> public int HorizontalEnd { get; } /// <summary> /// 这一笔画在水平方向上的长度。 /// </summary> public int HorizontalLength { get; } /// <summary> /// 另一笔画必须越过这些阈值点,才被认为和这一笔画重合。 /// </summary> public int OverlayMaxStart { get; } public int OverlayMinEnd { get; } private bool CheckPosition(StrokeRecord other) { return (other.HorizontalStart < OverlayMaxStart) || (OverlayMinEnd < other.HorizontalEnd); } /// <summary> /// 检查另一笔画是否和这一笔画重叠。 /// </summary> /// <param name="other"></param> 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 = allStrok