图像描边是设计软件中常见的图像处理功能,在 Photoshop 中,图像应用的描边后的效果是这样:javascript
看起来是一个很简单的功能,那就让咱们来看看在 WEB 中有哪些方法可以实现描边。java
既然 Canvas 没有内置,那万能的 SVG 有没有呢?SVG 里有许多有趣的滤镜,其中的 feMorphology
能够达到将某些元素进行「扩张」或者「腐蚀」的效果。咱们能够用它实现 文字描边。那若是将它应用在图像上呢?git
好吧,看起来效果和咱们的需求相去甚远,SVG 方案走不下去了。github
咱们先选择一张简单的矩形图像,若是将它进行填充并复制 8 份,把这 8 张分别沿着上、下、左、右、左上、左下、右上、右下八个方向进行偏移,就能完成对矩形图像的描边。不过它的描边结果不「圆润」,若是复制更多份,好比 360 份,让图像往 360 个方向进行偏移不就能作出圆角了吗?让咱们看看结果:web
不过这个方案有着很多缺点:算法
虽然这个方案有些粗暴,可是它不涉及任何算法,更像是一个脑经急转弯,实现成本至关低。针对性能问题,若是能够迁移到 WebGL 上会有不小的提高(嗯?门槛好像变高了?), pixi.js 的描边)就是这样的实现。app
仔细想一想,描边说到底不就是描出边缘吗?若是可以提取出图像的边缘,是否是一切问题就迎刃而解了呢?ide
咱们经过使用 Marching squares 算法 可以从图像中提取出轮廓,获得轮廓路径后,以后只须要将路径绘制出来就好了。为了达到描边边缘圆润的效果,咱们须要设置 lineJoin
为 round
.svg
const outlineWidth = 20 const path = getPath(image) ctx.lineJoin = 'round' ctx.lineWidth = outlineWidth * 2 drawPath(ctx, path) ctx.drawImage(image)
再来看看结果,就算是大半径的描边也能正常输出:函数
这个方案好像又快又好,并且也能处理描边宽度过大的状况。不过仍是勉强能挑出缺点:
在轮廓提取的方向上还有另外一个思路,咱们可以获得图像的边缘以后,再算出整张图像里每一个像素点到最近的边缘的距离。当描边宽度等于这个距离时,咱们就填充这个像素点,这样便实现了描边。
Distance transform 是一种计算二值图各像素点到边缘距离的算法。经过一段简单的代码理解一下这个算法:
const getPixelByPosition = (pixels, x, y) => { alpha: 0 } const checkTransparent = pixel => pixel.alpha < 255 // 欧拉距离计算 const euclideanDistance = (x1, y1, x2, y2) => ( sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) ) for(let pixel of pixels) { const isTransparent = checkTransparent(pixel) let { x, y } = pixel let min = Infinity // 判断目标是否位于图像边缘 for(let ox = 0; ox < width; ox++) { for(let oy = 0; oy < height; oy++) { const current = getPixelByPosition(pixels, ox, oy) if( // 当前像素透明而且目标像素不透明 isTransparent && !checkTransparent(current) || // 当前像素不透明而且目标像素透明 !isTransparent && checkTransparent(current) ) { min = Math.min(euclideanDistance(x, y, ox, oy), min) } } } }
一句话说明就是逐像素地查找距离边缘的最短距离。不过这段代码复杂度过高了,实际场景根本没法用。咱们能够选择现成的优秀算法,不过不管如何优化,复杂度也低不了多少,通过测试,2000 * 2000px 的图像须要 300ms。因此对于大尺寸图像,这个方案注定快不起来。尽管如此,当只要计算出距离数据后,以后的渲染和更新都再也不是问题,咱们能够轻松得作到实时更新描边结果。
另外,这类像素操做若是不通过抗锯齿的处理每每会产生「毛刺」,实时 CPU 锯齿计算显然不是一个好选择,因而咱们就只剩 WebGL 可用了。那么在 WebGL 中如何解决这类简单的「毛刺」呢?在 The Book Of Shaders 中经过 smoothstep
画出了一个更 「圆」 的圆,咱们也能够基于此函数来解决这个「毛刺」问题。
这个方案除了初始化距离数据的时间过长之外,几乎没有其余缺点,而且相比其余方案,咱们能够经过使用 不一样的距离函数 来达到不一样的描边效果。这个方案有很多现成的应用,例如 tiny-sdf。
至此,描边的方案分享就结束了。看似简单的描边,对于算法菜鸡的我仍是有着不低的难度。总结一下以上三个方案,这几个方案都各有优缺点,从性能、效果和门槛三个维度上来看排名大体是以下(针对 2000 * 2000px 的图像而言):