因产品需求,我们打造了自主的画笔组件InkCanvas,在我们的项目纸笔课堂、晓课堂中得到了很好的应用。同时我们也通过技术输出,在直播云项目中集成了组件的核心算法,升级了其涂鸦功能中的笔迹展示效果,在多类终端(Windows、Mac、Iphone/IPad、Android Phone/Pad)中,都有不错的表现。
我们今天就来分享一下画笔组件的核心算法之一 —— 手写笔迹还原算法。
手写笔迹还原是将一系列有序的坐标采样点,转换为连续的笔划线条曲线的过程。
比如我们用直线线段将这些点逐个连接起来,就实现了一个最简单的笔迹还原算法。但是这种算法效果肯定不会太好,比如不够光滑(当输入点比较稀疏时),连接点周边的曲线不连续。
所以我们需要一个更强大的算法,针对笔迹采样点做更进一步的特征提取,做更细致的处理,用更好的方案去定义、计算其几何轮廓,这样才能够还原出一些“原汁原味”的效果,也就是我们常说的体现书写风格。
1、笔迹输入设备
在讨论具体的还原算法之前,我们先要介绍一下笔迹点输入相关一些知识。
画笔笔迹输入来源于各种各样的设备,比如手写板,触摸屏,甚至于普通的鼠标也可以作为笔迹的输入设备。不管什么设备,其输出的都是一系列带元数据的坐标点,有些设备能够感知手写压力(比如通过电感线圈),还有些设备能够识别笔尖的粗细(接触面积)。设备的采样率也会不同,每秒10~200个点都有。
因此一个坐标点,其元数据可能包含:压力、接触面积、时间戳等。我们的画笔组件主要处理压力值,根据压力改变笔迹的粗细。
2、笔迹还原算法结构
通过尝试我们发现,手写笔迹还原最关键的两个效果是平滑和笔锋,有了这两个效果,差不多能够很好的体现书写风格。
要做到平滑,我们需要对输入的笔迹进行一些整理,我们会合并一些点,也会补充一些点,其中最关键的是 Bezier 插值算法的应用。
要还原笔锋,主要是对压力值做细致的处理,能够还原出笔划的粗细变化,同时让这种粗细变化尽量平滑。
当然,性能要求也是很关键的,毕竟实时书写的场景是在教学中是最常见的,所以算法本身也不能太过复杂,输出的图形不能对渲染模块有太高的要求和太大的压力。
最终,我们的笔迹还原方案使用的下面这样的算法结构。整个算法主要分为“路径整理”和“路径转换”两部分,每个部分又分别包含一些小的步骤,下面我们逐个介绍。
3、路径整理
针对输入的原始路径,我们需要做两方面的整理。
一方面,如果输入某一段路径的笔迹点比较稠密,并且路径也比较连续,那么我们可以用简化的路径来代替这部分路径,这样能够提升计算及渲染的性能,也减少了采样噪声(局部的轻微凸出点)、采样精度带来的影响。
另一方面,如果某一段路径的笔迹点比较稀疏,我们就需要在该路径上补充一些点,以达到减少曲率,增加圆滑度的效果。
需要说明的是,这里的算法需要在物理尺寸的分辨率下进行,这样还原出来的笔迹更贴近自然效果。实际上,所有经验参数都是基于物理尺寸实验确定的。
下面的对比图可以看出路径整理的效果。
这组数据来源于某个型号的手写板。左图是原始输入路径,右图是处理后的笔迹路径,通过插值补充了一些点,使得曲线变得更平滑。
下面我们详细介绍其中的算法过程。
3.1、预处理
在预处理阶段,需要做下列工作:
- 去除掉一些重复点
当相邻两个点的距离很小时,可以认为是重复点,去除其中一个。去重后的点保存于点数组 P 中。
- 转换为物理尺寸
将点的坐标转换到以Himetric(缇)为单位的值上,Himetric=0.001cm,采用常见的96dpi屏幕密度,这个转换就是乘以(2540/96)。
P[i] = P[i] * (2540.0 / 96.0)
- 计算路径累计长度 N
n[0] = 0;n[i]= n[i - 1] + length(P[i] - P[i - 1]);其中 length 为二维矢量长度,整个路径累计长度我们记为 l。
3.2、关键点分析(岐点)
岐点是笔迹路径上不连续的点,也是路径上关键特征点,在路径整理过程中,岐点是保留不做变动的。
首先我们计算所有点的包围矩形 R
R 的半周长为 d,结合之前计算的路径累计长度 l,点的总个数 c,我们计算出一个关键参数 s:
这个 s 怎么理解呢?首先 l / c 是相邻两点之间的平均距离, l / d 是可以看作是图形的分形维数。所以 s 描述的是最小可分辨路径长度,小于该长度的路径作为一个整体处理,不再还原其内部细节。
在上面的例子里面,这些数值为:
c = 36
d=1983.813 (约2cm)
l = 3582.698(约3.6cm)
s = 134.796 (约1.35mm)
在有了 s 之后,我们接下来寻找岐点。
首先需要计算路径上每一点的曲率,方法如下:对路径的每一个点,分别找出前后两个到该点的累计距离不小于 s 的第一个点,然后计算三点夹角的曲率(1 - 余弦cos)。
当三点在一条直线上,且方向一致时,曲率为0,三点形成直角时,曲率为1,但方向相反时,曲率为2。所以曲率越大,说明在该点的路径方向变化越大。
当曲率大于 0.8 时 (78°),再找出附近所有点中曲率最大的点,这个点就是一个岐点。
为了加快计算,当曲率小于 0.035 时 (15°)时,直接跳过附近(距离小于s)的点。
在上面的例子中,我们找到了6各岐点(包括两个端点,分别为第0,7,16,22,29,35个点)。
3.3、路径分段及方向计算
在这一步,我们对两个相邻岐点之间的路径切割为小段,并计算出路径在分段起点和终点的方向,为下一步的曲线拟合做准备。
分段的依据如下:
1、至少包含4个点(包含端点),除非碰到了岐点,有可能小于3
2、可以包含更多的点,只要这些点的方差小于某个值。
方差的计算方法如下:
取这些点中的5个点(包括两个端口,其他点均匀发布)p[i],i=0,1,2,3,4,5
令矢量 ,其中
则方差
因为路径累计距离n是有方向性的,所以c[1]、c[3]是负数。
当这5个点中所有相邻点的距离都相等时,c=[32/3, -128/3, 64, -128/3, 32/3],如果进一步这5个点在一条直线上,那么 c = 0。
完成分段后,还需要计算分段起点和终点的方向(切线)。
对于一个点P的切线方向,其切线 T 的计算方法如下:
- 如果该点是岐点:考虑后续(对于终点,则是前面)两个点:A、B,
- 如果是中间点,考虑前面两个点 A、B 及后续一个点 C,
,并且下一个分段起点的切线与上一个分段终点的切线相反。
这里的方向都是指向内部的,所以在同一个点,前后两个分段的方向是相反的。
3.4、曲线拟合
曲线拟合就是用样条曲线来替代原先的分段路径。采用3阶 Bezier 拟合,每个曲线除了两个端点,还需要计算两个控制点。根据分段点的个数不同,计算的方式也不一样,具体为:
- 2个点:实际上退化为直线,控制点为连线的两个3等分点
- 3个点:退化为二次抛物线,假设二级 Bezier 的三个控制点为 A、B、C,且参数为 t 时,对应到中间点 P,即:
,其中
那么:
提升为3阶,则:
- 超过3个点:通过最小方差拟合为3阶曲线,同时还需要结合端点的切线,具体算法略过。
3.5、曲线展开
曲线展开就是将3阶Bezier曲线离散化,这里关键的问题是要离散到多少个点。
计算点的个数,取决于两个参数:t 和 c。
t 与笔迹粗细对数级相关,粗细为一个像素时,t 大概在3.98 缇(0.03mm);
c 与曲线的曲率有关,它是 Bezier 4个控制点组成的 2 组相邻三角形的中线长度中的较大者。
但 时,只展开为一个点(终点),否则展开为 sqrt(c / t) + 3 个点。可以看出,笔迹越粗,曲线越平直,展开的点越少。
最后将点的坐标转换为像素单位乘以(96/2540),就完成了我们整个笔迹点整理的过程。
4、路径转换
在这个阶段,我们将笔迹点的路径转换为可渲染的路径。过程中需要处理压力值,还有考虑各节点连接的平滑性。在生成渲染路径后,就可以交由渲染模块去展示了。
路径渲染通常有“轮廓”和“填充”两种模式,我们采用填充模式,因为“轮廓”渲染会涉及到各种线形样式配置,且各个渲染实现也不一致;相反“填充”模式就显得简单、明确,这也是文字字体系统使用的渲染方式。
渲染路径通常是有一系列作图命令组成,命令有下列类型:
- MoveTo(A),移动当前点P,开始一段新路径
- LineTo(A),画线到指定点A,连接P和A,完成作图后,当前点 P 变为 A
- ArcTo(R, A),以指定半径画椭圆弧到指定点 A,当前点 P 也在弧上,完成作图后,当前点 P 变为 A
- QuadraticBezierTo(A, B),以控制点 A 和端点 B,画一段二级 Bezier 曲线,当前点 P 是曲线的起点,完成作图后,当前点 P 变为 A
- BezierTo(A, B, C),以控制点 A、B 和端点 C,画一段三级 Bezier 曲线,当前点 P 是曲线的起点,完成作图后,当前点 P 变为 A
所以我们最终生成的渲染路径是一个长条包围区域,如下图:左边是轮廓示意图,右边是最终渲染效果。
4.1、压力插值
因为整理后的点,很多都不是原始点了,所以没有附加压力信息。这就需要我们恢复出每个点的压力信息,这样后续生成的渲染路径才能够反应出原始的压力变化。
这里我们通过路径累计长度来近似计算每个Bezier点的压力值,依据的是一个假设:即路径整理后,路径的长度没有变化。
将路径拉直后,每一个Bezier点 P (位置l(P))一定在两个相邻的原始点 A、B 之间,通过A、B的压力值p(A)、p(B)及路径位置l(A)、l(B),可以线形拟合出 P 点的压力值 p(P):
有了压力信息,下面可以正式进入路径生成的环节了。
4.2、节点合并
所谓节点,就是Bezier路径上的点,但是节点上还记录着与它连接的前序点。因为可能合并节点,所以前序点不一定是Bezier路径的前一个点,有可能是更前一个点。
在讨论合并之前,还需要说明一下点的形状。点的形状一般是正方形(长方形)或者圆形(椭圆型),其尺寸与压力值成比例。另外,每个点有其包围矩形,即包围其形状的最小矩形,包围矩形是计算节点合并的依据。
两个矩形之间存在包含关系,这里包括不是绝对的,只要相交区域大于一定比例(比如95%),就认为是包含的,而且规定面积大的矩形包含面积小的矩形。
我们在合并时要考察当前2个或者3个相邻节点的包含关系(即包围矩形包含关系),当节点有包含关系时,合并或者丢弃节点。如下图:
合并与丢弃有一点区别。如果当前节点包含前序节点,则合并,后续节点的前序节点是当前节点对应的点。如果当前节点被前序节点包含,则丢弃当前节点,后续节点的前序节点是前序节点对应的点。
当我们有了3个连续节点,就可以进入下一个处理阶段。
4.2、节点分段
此时我们有3个连续节点PP、P、C,分段从 PP 开始,然后不停的用 C 点连接 P 点,并滑动 PP < P < C,补充新的 C 点,当分段结束时,在当前 P 点结束。
分段结束时,仍然滑动 PP < P < C,下个分段从当前的 P 点开始。
什么情况下应该结束分段呢?
总的来说,我们考虑前后节点的方向变化、包围矩形面积变化,以及 PP、C的包围矩形是否相交。当方向、大小变化超过阈值时,就需要重新开始分段。
从之前的渲染路径轮廓图(下图),可以看出整个渲染路径有多个分段,且一般在方向变化时开始新的分段。新分段的起点与上一个分段终点重叠。
4.3.、节点连接
节点连接是将节点对应的点与节点的上一个点连接(不一定是Bezier路径的上一个点),以及端点的闭合连线。
因为最终生成的是一个包围路径,所以需要用两个路径序列分别表示从起点到终点和从终点到起点的路径轮廓。这两个序列分别称为 AB 序列,DC序列。
在生成包围路径时,需要考虑点的形状(矩形,椭圆型),针对不同的点的形状,有不同的处理方式。
4.3.1、矩形风格
对于矩形,两个矩形的连接有四根线(顶点连线,如下图),哪根连线应该加入 AB 序列,哪根连线应该加入 DC 序列呢?
其实有这样一个规则,当通过某个顶点的两个边的方向与该点的连线方向都一致时,则该点为 A 点;都不一致时,该点为 C 点;分别对应另一个矩形的两个的顶点 B、D 。AB连线加入AB序列,DC连线(反向)加入DC序列。
对于起点,顺着矩形边的方向从 C 到 A 的连线也加入AB序列;对于终点,从 B 到 D 的连线也加入AB 序列。
下列图描述了渲染路径轮廓构建的过程,圆圈是起点,红色线是AB序列的线,蓝色线是DC序列的线,但是DC序列中的线的加入顺序是反过来的。
4.3.2、椭圆风格
对于椭圆,有两个连接公切线,同样依据方向一致性原则,指定为AB、DC线。分别加入AB 、DC序列。但是与矩形有几不同点:
- 在起点、终点处,有一段椭圆弧连接线
- 在中间点,根据节点方向变化,在内外侧有一些特殊处理
- 内侧需要计算前后两段连线(如下图两段DC线)的交点,在交点处连接
- 外侧需要在两个切点(同一个椭圆上)之间增加一段椭圆弧,以闭合路径,并保持路径的平滑性
计算椭圆公切线,是一个比较复杂的工程,实现中我们用圆(短半径)的切点代替,然后拉伸到椭圆半径上。
5、后记
至此,我们完成了手写笔迹还原算法的介绍,欢迎大家留言讨论。
核心算法基于C++及stl库实现(开源地址),通过API对接,可以移植到大部分平台。不过 Web JS 平台好像无法集成 native 库。
除了API对接,我们还准备通过SVG格式对接,让组件能够输出SVG图片。