WEB 中的透明圖像描邊

圖像描邊是設計軟件中常見的圖像處理功能,在 Photoshop 中,圖像應用的描邊後的效果是這樣:
在這裏插入圖片描述

那麼如何在 WEB 中實現這樣的一個描邊呢?筆者在 Google 上混跡了有一段時間,發現了這個功能並不簡單,本文是記錄關於 WEB 中描邊的一些常見實現。

SVG 濾鏡

SVG 裏有許多有趣的濾鏡,其中的 feMorphology 可以達到將某些元素進行「擴張」或者「腐蝕」的效果。我們可以將它用於 文字描邊。那如果將它應用在圖像上呢?
在這裏插入圖片描述
demo

好吧,看起來效果和我們的預期相去甚遠,這條路基本走不下去了。

圖像偏移

我們先選擇一張簡單的矩形圖像,如果將它進行填充並複製 8 份,把這 8 張分別沿着上、下、左、右、左上、左下、右上、右下八個方向進行偏移,就能完成對矩形圖像的描邊。不過它的描邊結果不「圓潤」,那麼如果複製 360 份,讓圖像往 360 個方向進行偏移不就能做出圓角了嗎?

但是這個方案有三個缺點:

  1. 耗時長,以一張 2000 * 2000px 的圖像爲例,在 Chrome 下完成一次描邊需要 150ms 左右,而在 firefox 下需要 1s ,這也就意味着我們可能無法實時應用描邊。
  2. 當描邊的寬度超過了實際的圖像尺寸後會出現鏤空的現象,所以在描邊寬度有限制。
    在這裏插入圖片描述
  3. 無法實現內描邊。

雖然這個方案有些粗暴且「殘疾」,但是它沒有任何依賴,實現成本相當低。針對性能問題,如果可以遷移到 WebGL 上會有不小的提升。

輪廓提取

仔細想想,描邊說到底不就是描出邊緣嗎?如果能夠提取出圖像的邊緣,是不是一切問題就迎刃而解了呢?

我們通過使用 Marching squares 算法 能夠從圖像中提取出輪廓,得到輪廓路徑後,之後只需要將路徑繪製出來就行了。爲了達到描邊邊緣圓潤的效果,我們需要設置 lineJoinround.

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

再來看看結果:

在這裏插入圖片描述
這個方案好像又快又好,而且也能處理描邊寬度過大的情況。不過還是有一些缺點:

  • 仔細觀察,描邊邊緣還是不夠平滑,如下:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-7fNZBn19-1581952560625)(https://liajoy.github.io/images/outline-by-marching-squares-edge.png)]在這裏插入圖片描述

  • 路徑越多,繪製就需要越長時間,可以通過一些 路徑簡化算法 來減少路徑點。

Distance transform

在輪廓提取的方向上還有另一個思路,當我們能夠得到圖像的邊緣,再算出整張圖像裏每個像素點到最近的邊緣的距離,當描邊寬度等於這個距離時,我們就填充這個像素點,就此完成了描邊。

Distance transform 是一種計算二值圖各像素點到邊緣距離的算法。一段用於理解 distance transform 的代碼如下(千萬別跑這段代碼):

const getPixelByPosition = (pixels, x, y) => 'none'
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 自然是唯一的選擇。那麼怎麼如何解決「毛刺」呢?在 這篇文章 中提到了如何畫一個更 “圓” 的圓,通過文章裏提到的 smoothstep 函數可以幫助我們繪製一個平滑的邊緣。

這個方案除了計算距離數據所需的時間過長以外,幾乎沒有其他缺點,並且相比其他方案,我們可以通過使用 不同的距離函數 來達到不同的描邊效果。這個方案有了一些現成的應用,例如 tiny-sdf

總結

一個 “小小” 描邊的坑越挖越深,越挖門檻越高,我已是無力再接着調研了。還是總結一下以上三個方案,這幾個方案都各有優缺點,從性能、效果和門檻三個維度上來看排名大致是如下(針對 2000 * 2000px 的圖像而言):

  • 性能:輪廓提取 > 圖像偏移 > Distance Transform
  • 效果:Distance Transform >= 圖像偏移 > 輪廓提取
  • 門檻:Distance Transform > 輪廓提取 > 圖像偏移

參考

發佈了9 篇原創文章 · 獲贊 0 · 訪問量 6953
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章