在2d圖形可視化開(kāi)發(fā)中,經(jīng)常要繪制對(duì)象的選中效果。 一般來(lái)說(shuō),表達(dá)對(duì)象選中可以使用邊框,輪廓或者發(fā)光的效果。 發(fā)光的效果,可以使用canvas的陰影功能,比較容易實(shí)現(xiàn),此處不在贅述。
繪制邊框
繪制邊框是最容易實(shí)現(xiàn)的效果,比如下面的圖片

要繪制邊框,只需要使用strokeRect的方式即可。效果如下圖所示:

這個(gè)代碼也很簡(jiǎn)單,如下所示:
ctx1.strokeStyle = "red";
ctx1.lineWidth = 2;
ctx1.drawImage(img, 1, 1,img.width ,img.height)
ctx1.strokeRect(1,1,img.width,img.height);
繪制輪廓
問(wèn)題是,簡(jiǎn)單粗暴的加一個(gè)邊框,并不能滿(mǎn)足需求。很多時(shí)候,人們需要的是輪廓的效果,也就是圖片的有像素和無(wú)像素的邊緣處。如下圖的效果所示:

要實(shí)現(xiàn)上述效果,最容易想到的思路就是通過(guò)像素的計(jì)算來(lái)判斷邊緣,并對(duì)邊緣進(jìn)行特定顏色的像素填充。但是像素的計(jì)算算法并不容易,簡(jiǎn)單的算法又很難達(dá)到預(yù)期的效果,而且由于逐像素操作,效率不高。
考慮到在三維webgl中,計(jì)算輪廓的算法思路是這樣的:
- 先繪制三維模型自身,并在繪制的時(shí)候啟動(dòng)模板測(cè)試,把三維圖像保存到模板緩沖中。
- 把模型適當(dāng)放大,用純屬繪制模型,并在繪制的時(shí)候啟用模板測(cè)試,和之前的模板緩沖區(qū)中的像素進(jìn)行比較,如果對(duì)應(yīng)的坐標(biāo)處在之前模板緩沖區(qū)中有像素,就不繪制純色。
依據(jù)上述的原理,就可以繪制處三維對(duì)象的輪廓了。下面是一個(gè)示例效果,(參考https://stemkoski.github.io/Three.js/Outline.html)

在2d canvas里面有類(lèi)似的原理可以實(shí)現(xiàn)輪廓效果,就是使用globalCompositeOperation了。 大體思路是這樣的:
- 首先繪制放大一些的圖片。
- 然后開(kāi)啟globalCompositeOperation = 'source-in', 并用純色填充整個(gè)canvas區(qū)域,由于source-in的效果,純色會(huì)填充放大圖片有像素的區(qū)域。
- 使用默認(rèn)的globalCompositeOperation(source-over),用原始尺寸繪制圖片。
繪制放大一些的圖片
通過(guò)drawImage的參數(shù)可以控制繪制圖片的大小,如下所示,drawImage有幾個(gè)形式:
1 void ctx.drawImage(image, dx, dy);
2 void ctx.drawImage(image, dx, dy, dWidth, dHeight);
3 void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
其中dx,dy 代表繪制的起始位置,一般繪制的時(shí)候使用第一個(gè)方法,代表繪制的大小就是原本圖片的大小。而使用第二個(gè)方法,我們可以指定繪制的尺寸,我們可以使用第二個(gè)方法繪制放大的圖片,代碼如所示:
ctx.drawImage(img, p - s, p - s, w + 2 * s, h+ 2 * s);
其中p代表圖片本身的繪制位置,s代表向左,向上的偏移量,同時(shí)圖片的寬和高都增加 2 * s
用純色填充放大圖片的區(qū)域
在上一步繪制的基礎(chǔ)上,開(kāi)啟globalCompositeOperation = 'source-in', 并用純色填充整個(gè)canvas區(qū)域。 代碼如下所示:
// fill with color
ctx.globalCompositeOperation = "source-in";
ctx.fillStyle = "#FF0000";
ctx.fillRect(0, 0, cw, ch);
最終的效果如下圖所示:

為什么會(huì)出現(xiàn)這種效果是因?yàn)槭褂昧薵lobalCompositeOperation = 'source-in',具體原理可以參考本人的其他文章。
繪制原始圖片
最后一步就是繪制原始圖片,代碼如下所示:
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(img, p, p, w, h);
首先恢復(fù)globalCompositeOperation為默認(rèn)值 "source-over",然后按照原本的大小繪制圖片。
經(jīng)過(guò)以上步驟,最終的效果如下圖所示:

可以看出最終獲得了我們要的效果。
只顯示輪廓
如果我們只想得到圖片的輪廓,則可以在最后繪制的時(shí)候,globalCompositeOperation 設(shè)置為“destination-out”,代碼如下:
ctx.globalCompositeOperation = "destination-out";
ctx.drawImage(img, p, p, w, h);
效果圖如下:

輪廓粗細(xì)不一致的問(wèn)題
上面的算法實(shí)現(xiàn),是在圖片的有像素值區(qū)域中心和圖片本身的幾何中心基本一直,如果圖片的有像素值的中心和圖片本身的幾何中心相差比較大,則會(huì)出現(xiàn)輪廓粗細(xì)不一致的情況,比如下面這張圖:

上半部分是透明的,下半部分是非透明的,像素的中心在3/4出,而幾何中心在1/2處。使用上面的算法,該圖片的輪廓如下:

可以發(fā)現(xiàn)上邊緣的輪廓寬度變成了0。
在比如下圖,

繪制后上邊緣的輪廓比其他邊緣的細(xì)。

怎么處理這種情況呢?可以在繪制放大圖片的時(shí)候,不直接使用縮放,而是在上下左右,上左,上右,下左,下右?guī)讉€(gè)方向進(jìn)行偏移繪制,多次繪制,代碼如下:
var dArr = [-1, -1, 0, -1, 1, -1, -1, 0, 1, 0, -1, 1, 0, 1, 1, 1], // offset array
// draw images at offsets from the array scaled by s
for (var i = 0; i < dArr.length; i += 2) {
ctx.drawImage(img, p + dArr[i] * s, p + dArr[i + 1] * s, w, h);
}
再看上面圖片的輪廓效果,如下所示:

半透明的情況
我在其他文章中說(shuō)過(guò),globalCompositeOperation為"source-in"的時(shí)候,source圖形的透明度,會(huì)影響到目標(biāo)繪制圖形的透明度。所以會(huì)導(dǎo)致輪廓的像素值會(huì)乘以透明度。比如,我們?cè)诶L制放大圖的時(shí)候,設(shè)置globalAlpha = 0.5進(jìn)行模擬。
最后的繪制效果如下:

可以看到輪廓的顏色變淺了,解決辦法就是多繪制幾次放大圖。比如:
ctx.globalAlpha = 0.5;
ctx.drawImage(img, p - s, p - s, w + 2 * s, h+ 2 * s);
ctx.drawImage(img, p - s, p - s, w + 2 * s, h+ 2 * s);
而上面通過(guò)偏移的方式繪制的時(shí)候,本身都繪制了好多遍,所以不存在這個(gè)問(wèn)題。如下:
ctx.globalAlpha = 0.5;
for (var i = 0; i < dArr.length; i += 2) {
ctx.drawImage(img, p + dArr[i] * s, p + dArr[i + 1] * s, w, h);
}
如下圖所示:

當(dāng)然,在透明度很低的情況下,使用繪制很多遍的方式,不是很好的解決方案。
使用算法(marching-squares-algorithm)
上面的方法對(duì)于有些圖片效果就很不好,比如這張圖片:

由于其有很多中空的效果,所以其最終效果如下圖所示:

但是想要的只是外部的輪廓,而不需要中空部分也繪制上輪廓效果。此時(shí)需要使用其他的算法。 直接使用marching squares algorithm 可以獲取圖片的邊緣。這一塊的算法具體實(shí)現(xiàn)本文不再講解,后續(xù)有機(jī)會(huì)單獨(dú)一篇文章進(jìn)行講解。 此處直接使用開(kāi)源的實(shí)現(xiàn)。比如可以使用 https://github.com/sakri/MarchingSquaresJS,代碼如下:
function drawOuttline2(){
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
var w = img.width;
var h = img.height;
canvas.width = w;
canvas.height = h;
ctx.drawImage(img, 0, 0, w, h);
var pathPoints = MarchingSquares.getBlobOutlinePoints(canvas);
var points = [];
for(var i = 0;i < pathPoints.length;i += 2){
points.push({
x:pathPoints[i],
y:pathPoints[i + 1],
})
}
// ctx.clearRect(0, 0, w, h);
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = '#00CCFF';
ctx.moveTo(points[0].x, points[0].y);
for (var i = 1; i < points.length; i += 1) {
var point = points[i];
ctx.lineTo(point.x,point.y);
}
ctx.closePath();
ctx.stroke();
ctx1.drawImage(canvas,0,0);
}
首先使用調(diào)用MarchingSquaresJS的方法獲取img圖像的輪廓點(diǎn)的集合,然后把所有的點(diǎn)連接起來(lái)。形成輪廓圖,最終效果如下:

不過(guò)可以看出,MarchingSquares 算法獲得的輪廓效果鋸齒相對(duì)較多的。有光這塊算法的優(yōu)化,本文不講解。
總結(jié)
對(duì)于沒(méi)有中空效果的圖片,我們一般不采用MarchingSquares算法,而采用前面的一種方式來(lái)實(shí)現(xiàn),效率高,而且效果相對(duì)更好。 而對(duì)于有中空,就會(huì)使用MarchingSquares算法,效果相對(duì)差,效率也相對(duì)低一些,實(shí)際應(yīng)用中,可以通過(guò)緩存來(lái)降低性能的損耗。
本文的起源來(lái)資源一個(gè)2.5D項(xiàng)目,上一張項(xiàng)目圖吧:

參考文檔
https://www.emanueleferonato.com/2013/03/01/using-marching-squares-algorithm-to-trace-the-contour-of-an-image/
https://github.com/sakri/MarchingSquaresJS
https://github.com/OSUblake/msqr
http://users.polytech.unice.fr/~lingrand/MarchingCubes/algo.html#squar