在2d圖形可視化開發中,經常要繪制對象的選中效果。 一般來說,表達對象選中可以使用邊框,輪廓或者發光的效果。 發光的效果,可以使用canvas的陰影功能,比較容易實現,此處不在贅述。
繪制邊框
繪制邊框是最容易實現的效果,比如下面的圖片
要繪制邊框,只需要使用strokeRect的方式即可。效果如下圖所示:
這個代碼也很簡單,如下所示:
ctx1.strokeStyle = "red";
ctx1.lineWidth = 2;
ctx1.drawImage(img, 1, 1,img.width ,img.height)
ctx1.strokeRect(1,1,img.width,img.height);
繪制輪廓
問題是,簡單粗暴的加一個邊框,並不能滿足需求。很多時候,人們需要的是輪廓的效果,也就是圖片的有像素和無像素的邊緣處。如下圖的效果所示:
要實現上述效果,最容易想到的思路就是通過像素的計算來判斷邊緣,並對邊緣進行特定顏色的像素填充。但是像素的計算算法並不容易,簡單的算法又很難達到預期的效果,而且由於逐像素操作,效率不高。
考慮到在三維webgl中,計算輪廓的算法思路是這樣的:
- 先繪制三維模型自身,並在繪制的時候啟動模板測試,把三維圖像保存到模板緩沖中。
- 把模型適當放大,用純屬繪制模型,並在繪制的時候啟用模板測試,和之前的模板緩沖區中的像素進行比較,如果對應的坐標處在之前模板緩沖區中有像素,就不繪制純色。
依據上述的原理,就可以繪制處三維對象的輪廓了。下面是一個示例效果,(參考https://stemkoski.github.io/Three.js/Outline.html)
在2d canvas里面有類似的原理可以實現輪廓效果,就是使用globalCompositeOperation了。 大體思路是這樣的:
- 首先繪制放大一些的圖片。
- 然后開啟globalCompositeOperation = 'source-in', 並用純色填充整個canvas區域,由於source-in的效果,純色會填充放大圖片有像素的區域。
- 使用默認的globalCompositeOperation(source-over),用原始尺寸繪制圖片。
繪制放大一些的圖片
通過drawImage的參數可以控制繪制圖片的大小,如下所示,drawImage有幾個形式:
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 代表繪制的起始位置,一般繪制的時候使用第一個方法,代表繪制的大小就是原本圖片的大小。而使用第二個方法,我們可以指定繪制的尺寸,我們可以使用第二個方法繪制放大的圖片,代碼如所示:
ctx.drawImage(img, p - s, p - s, w + 2 * s, h+ 2 * s);
其中p代表圖片本身的繪制位置,s代表向左,向上的偏移量,同時圖片的寬和高都增加 2 * s
用純色填充放大圖片的區域
在上一步繪制的基礎上,開啟globalCompositeOperation = 'source-in', 並用純色填充整個canvas區域。 代碼如下所示:
// fill with color
ctx.globalCompositeOperation = "source-in";
ctx.fillStyle = "#FF0000";
ctx.fillRect(0, 0, cw, ch);
最終的效果如下圖所示:
為什么會出現這種效果是因為使用了globalCompositeOperation = 'source-in',具體原理可以參考本人的其他文章。
繪制原始圖片
最后一步就是繪制原始圖片,代碼如下所示:
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(img, p, p, w, h);
首先恢復globalCompositeOperation為默認值 "source-over",然后按照原本的大小繪制圖片。
經過以上步驟,最終的效果如下圖所示:
可以看出最終獲得了我們要的效果。
只顯示輪廓
如果我們只想得到圖片的輪廓,則可以在最后繪制的時候,globalCompositeOperation 設置為“destination-out”,代碼如下:
ctx.globalCompositeOperation = "destination-out";
ctx.drawImage(img, p, p, w, h);
效果圖如下:
輪廓粗細不一致的問題
上面的算法實現,是在圖片的有像素值區域中心和圖片本身的幾何中心基本一直,如果圖片的有像素值的中心和圖片本身的幾何中心相差比較大,則會出現輪廓粗細不一致的情況,比如下面這張圖:
上半部分是透明的,下半部分是非透明的,像素的中心在3/4出,而幾何中心在1/2處。使用上面的算法,該圖片的輪廓如下:
可以發現上邊緣的輪廓寬度變成了0。
在比如下圖,
繪制后上邊緣的輪廓比其他邊緣的細。
怎么處理這種情況呢?可以在繪制放大圖片的時候,不直接使用縮放,而是在上下左右,上左,上右,下左,下右幾個方向進行偏移繪制,多次繪制,代碼如下:
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);
}
再看上面圖片的輪廓效果,如下所示:
半透明的情況
我在其他文章中說過,globalCompositeOperation為"source-in"的時候,source圖形的透明度,會影響到目標繪制圖形的透明度。所以會導致輪廓的像素值會乘以透明度。比如,我們在繪制放大圖的時候,設置globalAlpha = 0.5進行模擬。
最后的繪制效果如下:
可以看到輪廓的顏色變淺了,解決辦法就是多繪制幾次放大圖。比如:
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);
而上面通過偏移的方式繪制的時候,本身都繪制了好多遍,所以不存在這個問題。如下:
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);
}
如下圖所示:
當然,在透明度很低的情況下,使用繪制很多遍的方式,不是很好的解決方案。
使用算法(marching-squares-algorithm)
上面的方法對於有些圖片效果就很不好,比如這張圖片:
由於其有很多中空的效果,所以其最終效果如下圖所示:
但是想要的只是外部的輪廓,而不需要中空部分也繪制上輪廓效果。此時需要使用其他的算法。 直接使用marching squares algorithm 可以獲取圖片的邊緣。這一塊的算法具體實現本文不再講解,后續有機會單獨一篇文章進行講解。 此處直接使用開源的實現。比如可以使用 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);
}
首先使用調用MarchingSquaresJS的方法獲取img圖像的輪廓點的集合,然后把所有的點連接起來。形成輪廓圖,最終效果如下:
不過可以看出,MarchingSquares 算法獲得的輪廓效果鋸齒相對較多的。有光這塊算法的優化,本文不講解。
總結
對於沒有中空效果的圖片,我們一般不采用MarchingSquares算法,而采用前面的一種方式來實現,效率高,而且效果相對更好。 而對於有中空,就會使用MarchingSquares算法,效果相對差,效率也相對低一些,實際應用中,可以通過緩存來降低性能的損耗。
本文的起源來資源一個2.5D項目,上一張項目圖吧:
參考文檔
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
如果對可視化感興趣, 關注公號“ITMan彪叔” 可以及時收到更多有價值的文章。也可以加微信541002349進行交流。