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 > 輪廓提取 > 圖像偏移

最後,放上基於本文的實踐倉庫在線預覽

參考

相關文章
相關標籤/搜索