WEB 中的透明图像描边

图像描边是设计软件中常见的图像处理功能,在 Photoshop 中,图像应用的描边后的效果是这样:javascript

outline-in-ps

看起来是一个很简单的功能,那就让咱们来看看在 WEB 中有哪些方法可以实现描边。java

SVG 滤镜

既然 Canvas 没有内置,那万能的 SVG 有没有呢?SVG 里有许多有趣的滤镜,其中的 feMorphology 能够达到将某些元素进行「扩张」或者「腐蚀」的效果。咱们能够用它实现 文字描边。那若是将它应用在图像上呢?git

outline-by-svg-filter

好吧,看起来效果和咱们的需求相去甚远,SVG 方案走不下去了。github

图像偏移

咱们先选择一张简单的矩形图像,若是将它进行填充并复制 8 份,把这 8 张分别沿着上、下、左、右、左上、左下、右上、右下八个方向进行偏移,就能完成对矩形图像的描边。不过它的描边结果不「圆润」,若是复制更多份,好比 360 份,让图像往 360 个方向进行偏移不就能作出圆角了吗?让咱们看看结果:web

outline-by-offset

不过这个方案有着很多缺点:算法

  1. 耗时长,以一张 2000 * 2000px 的图像为例,在 Chrome 下完成一次描边须要 150ms 左右,而在 firefox 下须要 1s ,这也就意味着咱们可能没法实时应用描边。
  2. 当描边的宽度超过了实际的图像尺寸后会出现镂空的现象,因此在描边宽度与图像尺寸上有限制。就像这样:

outline-by-offset-bad-result

  1. 没法实现内描边。

虽然这个方案有些粗暴,可是它不涉及任何算法,更像是一个脑经急转弯,实现成本至关低。针对性能问题,若是能够迁移到 WebGL 上会有不小的提高(嗯?门槛好像变高了?), pixi.js 的描边)就是这样的实现。app

轮廓提取

仔细想一想,描边说到底不就是描出边缘吗?若是可以提取出图像的边缘,是否是一切问题就迎刃而解了呢?ide

咱们经过使用 Marching squares 算法 可以从图像中提取出轮廓,获得轮廓路径后,以后只须要将路径绘制出来就好了。为了达到描边边缘圆润的效果,咱们须要设置 lineJoinround.svg

const outlineWidth = 20
const path = getPath(image)
ctx.lineJoin = 'round'
ctx.lineWidth = outlineWidth * 2
drawPath(ctx, path)
ctx.drawImage(image)

再来看看结果,就算是大半径的描边也能正常输出:函数

outline-by-marching-squares

这个方案好像又快又好,并且也能处理描边宽度过大的状况。不过仍是勉强能挑出缺点:

  • 描边边缘仍是不够平滑,以下:

outline-by-marching-squares-edge

  • 路径越多,绘制就须要越长时间。对此,能够经过一些 路径简化算法 来减小路径点。

Distance transform

在轮廓提取的方向上还有另外一个思路,咱们可以获得图像的边缘以后,再算出整张图像里每一个像素点到最近的边缘的距离。当描边宽度等于这个距离时,咱们就填充这个像素点,这样便实现了描边。

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 的图像而言):

  • 性能:轮廓提取 > 图像偏移 > Distance Transform
  • 效果:Distance Transform >= 图像偏移 > 轮廓提取
  • 门槛:Distance Transform > 轮廓提取 > 图像偏移

最后,放上基于本文的实践仓库在线预览

参考

相关文章
相关标签/搜索