- 原文作者:aleen42
- 譯文出自:掘金翻譯計劃
- 譯者:Jiang Haichao
- 校對者:L9m Mark
因為我司給我一個在瀏覽器中以編程方式來實現(xiàn)繪圖的需求,如下圖 1.1 所示,我想分享一些用 JavaScript 繪畫的要點苍在。實際上,我們畫啥呢释牺?答案是任一種圖像和圖形。
這里有個樣例称开,你可以直接點擊 http://draw.soundtooth.cn/ 查看八毯。并拖拽任意圖片皮获,放置到紅色方框內焙蚓,點擊 "Process" 按鈕,啟動繪圖方法:
注意:這個項目的版權是我司的洒宝,所以并不會向社區(qū) 開源 代碼主届。
項目開始時,我深受 這篇文章 中光線動畫繪制的啟發(fā)待德。如果仔細閱讀,你會發(fā)現(xiàn)枫夺,在繪制任何圖形之前都需要路徑數(shù)據(jù)将宪,有了這些數(shù)據(jù),我們才能夠模擬繪畫橡庞。這些數(shù)據(jù)的形式應該像下面這樣:
M 161.70443,272.07413
C 148.01517,240.84549 134.3259,209.61686 120.63664,178.38822
C 132.07442,172.84968 139.59482,171.3636 151.84309,171.76866
你可能會問较坛,這樣的 path
數(shù)據(jù)只在 SVG 元素中有效,怎么能繪制其他像 JPG扒最、PNG丑勤、或者 GIF 這樣的圖片呢。這是我們在本文后面將探討的問題吧趣。在那之前法竞,我們先簡單繪制一幅 SVG 圖像。
繪制 SVG 文件
什么是 SVG强挫?可伸縮矢量圖形岔霸,又稱為 SVG,是針對二維圖形基于 XML 的矢量圖片格式俯渤,支持動畫交互呆细。不支持老舊的 IE 瀏覽器。如果你是設計師八匠,或者是經常使用 Adobe Illustration 做繪圖工具的插畫家絮爷,也許已經對圖形已經有了一定的認知。但與一般圖形主要的不同在于梨树,SVG 是可伸縮的無損的坑夯,而其他格式的圖片不是。
注意:一般來說劝萤,SVG 格式的圖片被稱作 圖形渊涝,而其他格式的被稱為 圖像。
從 SVG 文件中提取數(shù)據(jù)
正如上文所說,在繪制 SVG 之前跨释,你需要從 SVG 文件中讀取數(shù)據(jù)胸私。這通常是 JavaScript 中 FileReader
這個對象的工作,它的初始化代碼片段像下面這樣:
if (FileReader) {
/** 如果瀏覽器支持 FileReader 對象 */
var fileReader = new FileReader();
}
作為一個 Web API鳖谈,FileReader
能夠讀取本地文件岁疼,readAsText
是其中支持讀取文本格式內容的方法之一。它可以觸發(fā)事先定義的 onload
方法缆娃,我們能夠在事件處理方法內部讀取內容捷绒。讀取內容的代碼應該如下所示:
fileReader.onload = function (e) {
/** SVG 文件內容 */
var contents = e.target.result;
};
fileReader.readAsText(file);
有了閱讀監(jiān)聽器,你也許會考慮是否還要用一個按鈕來上傳文件」嵋現(xiàn)在看來暖侨,那是普通沒有任何吸引力的交互方式。于此崇渗,我們可以通過拖放來優(yōu)化這類交互字逗。這意味著你能夠拖拽任何圖形并且放置到讀取內容的方框里。因為我的項目的優(yōu)先技術選型是 Canvas宅广,我將通過設置事件監(jiān)聽器和注冊一個 canvas 的 drop
事件來實現(xiàn)這種交互葫掉。
/** Drop 事件處理 */
canvas.addEventListener('drop', function (e) {
/** 從 e 中提取 `file` 對象 */
var file = e.dataTransfer.files[0];
/** 開始讀取文件內容 */
fileReader.readAsText(file);
});
數(shù)據(jù)加工
現(xiàn)在數(shù)據(jù)已經存儲在 contents
變量里,并且已經能夠處理它跟狱,數(shù)據(jù)對我們來說只是文本而已俭厚。開始時,我嘗試使用常規(guī)方法提取路徑節(jié)點驶臊。
var paths = contents.match(/<path([\s\S]+?)\/>/g);
但是這個方法有兩個缺點:
- 會丟失整個 SVG 文件結構挪挤。
- 不能創(chuàng)建一個合法的
SVGPathElement
DOM 元素。
為了更直白地說明关翎,請看如下代碼:
if (paths) {
var pathNodes = [];
var pathLen = paths.length;
for (var i = 0; i < pathLen; i++) {
/** 創(chuàng)建一個合法的 SVGPathElement DOM 節(jié)點 */
var pathNode = document.createElementNS('http://www.w3.org/2000/svg', 'path');
/** 使用臨時 div 元素电禀,方便讀取屬性 `d` */
var tmpDiv = document.createElement('div');
tmpDiv.innerHTML = paths[i];
/** 設置合法的 `d` 屬性 */
pathNode.setAttribute('d', tmpDiv.childNodes[0]
.getAttribute('d')
.trim()
.split('\n').join('')
.split(' ').join('')
);
/** 存儲在一個數(shù)組里 */
pathNodes.push(pathNode);
}
}
正如你看到的, tmpDiv.childNodes[0]
不是一個 SVGPathElement
笤休,所以我們需要創(chuàng)建另一個節(jié)點尖飞。如果我用另一個方法讀取整個 SVG 文件,SVGPath
變量能夠以清晰的結構存儲整個 SVG 對象店雅,并且可以隨意訪問:
var tempDiv = document.createElement('div');
tempDiv.innerHTML = contents.trim()
.split('\n').join('')
.split(' ').join('');
var SVGNode = tempDiv.childNodes[0];
用遞歸的方式可以很容易地提取所有 SVGPathElement
并且直接送入 pathNodes
棧政基。
var pathNodes = [];
function recursivelyExtract(parentNode) {
var children = parentNode.childNodes;
var childLen = children.length;
/** 如果節(jié)點沒有孩子節(jié)點,則直接返回 */
if (childLen === 0) {
return;
}
/** 循環(huán)子節(jié)點闹啦,如果子節(jié)點是 SVGPathElement沮明,則提取出來 */
for (var i = 0; i < childLen; i++) {
if (children[i].nodeName === 'path') {
pathNodes.push(children[i]);
}
}
};
recursivelyExtract(SVGNode);
使用那種方法看起來優(yōu)雅多了,至少我是這么認為的窍奋,尤其是和其他元素一起繪制的時候荐健,我只用 switch
結構就能提取不同元素酱畅,而不是使用一些常規(guī)表達。一般來說江场,在一個 SVG 文件里纺酸,圖形元素除了可以被定義成 path
,還可被定義成 circle
址否,rect
餐蔬,polyline
。所以佑附,我們應該怎么處理他們樊诺?答案是用 JavaScript 就能全部轉換成 path
元素,這個稍后再說音同。
我在開發(fā)項目的時候有一個問題是到底需要重點關注什么词爬。在一個復合路徑中,m
和 M
完全不一樣权均,必須要有至少一個 m
或者一個 M
缸夹,所以你必須把他們分離出來,避免兩條路徑相互影響螺句。也就是說,如果一條路徑屬于復合路徑橡类,則區(qū)分這兩個符號:
function generatePathNode(d) {
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
return path;
};
var d = children[i].getAttribute('d');
/** 分離復合路徑 */
var ds = d.match(/m[\s\S]+?(?=(?:m|$)+)/ig);
var dsLen = ds.length;
/** 復合路徑 */
if (dsLen > 1) {
/**
* 區(qū)分 `m` 和 `M`
* ...
*/
} else {
pathNodes.push(children[i]);
}
用 Canvas 作圖
注意:路徑已經在提取出來并存儲在本地變量中蛇尚,下一步要做的是用點繪制出來:
var pointsArr = [];
var pathLen = pathNodes.length;
for (var j = 0; j < pathLen; j++) {
var index = pointsArr[].push([]);
var pointsLen = pathNodes[j].getTotalLength();
for (var k = 0; k < pointsLen; k++) {
/** 從路徑中提取點 */
pointsArr[index].push(pathNodes[j].getPointAtLength(k));
}
}
如你所見,pointsArr
是一個二維數(shù)組顾画,第一維是路徑取劫,第二維是每個路徑下的點。當然研侣,這些點是能用 Canvas 畫出來的谱邪,如下:
/** 根據(jù)所給 index 繪制路徑 */
function drawPath(index) {
var ctx = canvas.getContext('2d');
ctx.beginPath();
/** 設置路徑 */
ctx.moveTo(pointsArr[index][0].x, pointsArr[index][0].y);
for (var i = 1; i < pointsArr[index].length; i++) {
ctx.lineTo(pointsArr[index][i].x, pointsArr[index][i].y);
}
/** 渲染 */
ctx.stroke();
}
試著考慮這樣一個問題:如果一條路徑包括盡可能多的可繪制點,如何優(yōu)化繪制方案更快速地繪制庶诡?也許惦银,跳著畫是解決的簡單之法,但是怎么跳著畫是另一個關鍵問題末誓。我還沒有發(fā)現(xiàn)完美解法扯俱,如果你有想法,歡迎交流喇澡。
function optimizeJump() {
var perfectJump = 1;
/**
* 計算最優(yōu)跨度值的算法
* ...
*/
return perfectJump;
}
function drawPath(index) {
var ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.moveTo(pointsArr[index][0].x, pointsArr[index][0].y);
/** 跳著畫的優(yōu)化方案 */
var perfectJump = optimizeJump();
for (var i = 1; i < pointsArr[index].length; i+= perfectJump) {
ctx.lineTo(pointsArr[index][i].x, pointsArr[index][i].y);
}
ctx.stroke();
}
算法是我們需要重點思考的迅栅。
校準參數(shù)
隨著需求越來越復雜,路徑數(shù)據(jù)無法適應比例縮放晴玖,改變大小或者移動圖形的場景读存。
為何要校準參數(shù)为流?
因為你可能要在Canvas當中對圖形進行比例縮放、調整尺寸让簿、移動敬察,這就意味著路徑數(shù)據(jù)也應該隨著你的改動來變化。但實際上它不能拜英,所以我們才需要校準參數(shù)静汤。
圖 2.1 展示了一個高亮工作區(qū)域,我叫它 面板居凶。在這個面板上虫给,你可以進行拖放,拖拽侠碧,調整尺寸或者移動操作抹估。實際上,面板里包含了一個能滿足你需求的 Canvas 對象弄兜。只需要把 SVG 文件(圖 2.2)拖放到面板里药蜻,就可以在屏幕上重繪,結果如圖 2.3:
富含中國元素的美麗 logo 就生成啦
除了圖形操作替饿,其他 SVG 屬性也會影響路徑數(shù)據(jù)语泽,比如 width
,height
和 viewBox
视卢。
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" viewBox="0 0 200 200">
<!-- paths -->
</svg>
所以踱卵,校準參數(shù)的計算受兩個因素影響,屬性 和 操作据过。
計算
計算之前惋砂,要了解定義的變量和代表的含義。
首先是圖形位置變量:
-
oriX: 圖形初始
x
值 -
oriY: 圖形初始
y
值 -
moveX: 移動前后
x
的差值. -
moveY: 移動前后
y
的差值. -
viewBoxX: 圖形的
viewBox
屬性的x
值 -
viewBoxY: 圖形的
viewBox
屬性的y
值
然后是圖形尺寸變量:
- oriW: 圖形初始寬度
- oriH: 圖形初始高度
- svgW: SVG 元素的寬度
- svgH: SVG 元素的高度
-
viewBoxW: SVG 元素的
viewBox
屬性的寬度 -
viewBoxH: SVG 元素的
viewBox
屬性的高度 - curW: 圖形的當前寬度
- curH: 圖形的當前高度
了解變量含義之后绳锅,我們可以開始計算校準參數(shù)了西饵。
用以下公式計算圖形的當前位置:
var x = oriX + moveX; /** 圖形的當前 x 值 */
var y = oriY + moveY; /** 圖形的當前 y 值 */
下面這個公式是用于計算比例:
var ratioParam = Math.max(oriW / svgW, oriH / svgH) * Math.min(svgW / viewBoxW, svgH / viewBoxH);
var ratioX = (curW / oriW) * ratioParam;
var ratioY = (curH / oriH) * ratioParam;
要記住 viewBox
屬性的 x
和 y
值會裁切圖形(如圖 2.4)。所以鳞芙,我們需要從初始點值中去掉這部分值眷柔。
我只需要邊緣點的最大值和最小值。舉個栗子原朝,如果點集的位置在圖形之外闯割,我就改變 x
或 y
,甚至全部改變竿拆,重寫到圖形的邊上宙拉。
point.x = point.x >= x && point.x <= x + curW ? point.x : ((point.x < x) ? x : x + curW);
point.y = point.y >= y && point.y <= x + curH ? point.y : ((point.y < y) ? y : y + curH);
據(jù)我所知,當點的數(shù)量很大的時候丙笋,刪除范圍外的點要比重寫更好谢澈。
把所有形狀變成路徑元素
現(xiàn)在煌贴,我們已經知道怎么用 JavaScript 繪制 path
元素。上文說道锥忿,在繪制 rect
牛郑、polyline
、circle
等其他元素定義的形狀之前敬鬓,應該先轉換成路徑淹朋。本節(jié)残揉,就來介紹一下做法廉赔。
圓與橢圓
圓和橢圓元素是近親,相同屬性如表 2.1 所示:
圓 | 橢圓 |
---|---|
CX | CX |
CY | CY |
表 2.1 相同屬性
不同屬性如表 2.2 所示:
圓 | 橢圓 |
---|---|
R | RX |
RY |
表 2.2 不同屬性
路徑轉換方法如下:
function convertCE(cx, cy) {
function calcOuput(cx, cy, rx, ry) {
if (cx < 0 || cy < 0 || rx <= 0 || ry <= 0) {
return '';
}
var output = 'M' + (cx - rx).toString() + ',' + cy.toString();
output += 'a' + rx.toString() + ',' + ry.toString() + ' 0 1,0 ' + (2 * rx).toString() + ',0';
output += 'a' + rx.toString() + ',' + ry.toString() + ' 0 1,0' + (-2 * rx).toString() + ',0';
return output;
}
switch (arguments.length) {
case 3:
return calcOuput(parseFloat(cx, 10), parseFloat(cy, 10), parseFloat(arguments[2], 10), parseFloat(arguments[2], 10));
case 4:
return calcOuput(parseFloat(cx, 10), parseFloat(cy, 10), parseFloat(arguments[2], 10), parseFloat(arguments[3], 10));
break;
default:
return '';
}
}
多邊形和隨意畫的圓
對于這些元素神汹,要提取 points
屬性数尿。按路徑元素的 d
值的特定格式重新組裝仑性。
/** 傳入 `points` 屬性的值*/
function convertPoly(points, types) {
types = types || 'polyline';
var pointsArr = points
/** 清除多余元素 */
.split(' ').join('')
.trim()
.split(/\s+|,/);
var x0 = pointsArr.shift();
var y0 = pointsArr.shift();
var output = 'M' + x0 + ',' + y0 + 'L' + pointsArr.join(' ');
return types === 'polygon' ? output + 'z' : output;
}
線段
一般來說,line
元素有多個屬性用于線的定位:x1
右蹦,y1
诊杆,x2
和 y2
。
很簡單何陆,我們可以這么計算:
function convertLine(x1, y1, x2, y2) {
if (parseFloat(x1, 10) < 0 || parseFloat(y1, 10) < 0 || parseFloat(x2, 10) < 0 || parseFloat(y2, 10) < 0) {
return '';
}
return 'M' + x1 + ',' + y1 + 'L' + x2 + ',' + y2;
}
矩形
矩形也有一些用于定位和決定大小的屬性:x
晨汹,y
,width
和 height
贷盲。
function convertRectangles(x, y, width, height) {
var x = parseFloat(x, 10);
var y = parseFloat(y, 10);
var width = parseFloat(width, 10);
var height = parseFloat(height, 10);
if (x < 0 || y < 0 || width < 0 || height < 0) {
return '';
}
return 'M' + x + ',' + y + 'L' + (x + width) + ',' + y + ' ' + (x + width) + ',' + (y + height) + ' ' + x + ',' + (y + height) + 'z';
}
形狀轉換成 path
的方法已經全部講解完畢淘这。你可以用這些方法繪制上述圖形。
繪制非 SVG 圖像晃洒,也就是圖片
除了 SVG 文件,我們還想繪制像 PNG朦乏,JPG球及,或者 GIF 格式的圖像。僅僅由像素數(shù)據(jù)組成呻疹,我們是無法直接使用的吃引。因此,我嘗試用計算機視覺領域的一個常見技術刽锤,Canny 邊緣檢測算法镊尺。用這種算法,可以簡單地找到位圖的輪廓并思。
尋找輪廓的整個步驟簡單概括為:灰度 -> 高斯模糊 -> Canny 梯度 -> Canny 非極大值抑制 -> Canny 磁滯 -> 掃描庐氮。這也是 Canny 邊緣檢測算法 的步驟。
在處理之前宋彼,我們要定義一些通用函數(shù)弄砍。第一個是 runImg
函數(shù)仙畦,通常用在從 Canvas 中加載圖片時,將其轉換成由數(shù)組組成的矩陣音婶。
/**
* [runImg: 從 Canvas 對象中加載圖片]
* @param {[type]} canvas [the canvas object]
* @param {[type]} size [the size of the matrix, like 3 for 3x3 matrixs]
* @param {Function} fn [callback function]
*/
function runImg(canvas, size, fn) {
for (var y = 0; y < canvas.height; y++) {
for (var x = 0; x < canvas.width; x++) {
var i = x * 4 + y * canvas.width * 4;
var matrix = getMatrix(x, y, size);
fn(i, matrix);
}
}
/**
* [getMatrix: 給定規(guī)模生成矩陣]
* @param {[type]} cx [the x value of the central point]
* @param {[type]} cy [the y value of the central point]
* @param {[type]} size [the size of the matrix you want to generate]
* @return {[type]} [return null if size is null, or return a matrix with a legal given size]
*/
function getMatrix(cx, cy, size) {
/**
* 給定 cx慨畸,cy,size衣式,圖片寬高寸士,生成 size x size 的二維數(shù)組
*/
if (!size) {
return;
}
var matrix = [];
for (var i = 0, y = -(size - 1) / 2; i < size; i++, y++) {
matrix[i] = [];
for (var j = 0, x = -(size - 1) / 2; j < size; j++, x++) {
matrix[i][j] = (cx + x) * 4 + (cy + y) * canvas.width * 4;
}
}
return matrix;
}
}
然而,針對 imgData
還有一些操作碴卧,imgData
是 Canvas 中 Context 對象的 Context.prototype.getImageData(x, y, width, height)
這 個 prototype 方法的返回值變量弱卡。
/**
* [getRGBA: 給定初始節(jié)點獲取 RGBA 值]
* @param {[type]} start [the point you want to know]
* @param {[type]} imgData [image data of the canvas]
* @return {[Object]} [return an object composed with r, g, b, and a attributes respectively]
*/
function getRGBA(start, imgData) {
return {
r: imgData.data[start],
g: imgData.data[start + 1],
b: imgData.data[start + 2],
a: imgData.data[start + 3]
};
}
/**
* [getPixel: 類似 getRGBA, 但包含合法性檢測]
* @param {[type]} i [the point you want to know]
* @param {[type]} imgData [image data of the canvas]
* @return {[Object]} [return an object composed with r, g, b, and a attributes respectively]
*/
function getPixel(i, imgData) {
if (i < 0 || i > imgData.data.length - 4) {
return {
r: 255,
g: 255,
b: 255,
a: 255
};
} else {
return getRGBA(i, imgData);
}
}
/**
* [setPixel: 與 getPixel 相反, 這個函數(shù)用于為特定點設值]
* @param {[type]} i [the point you want to set]
* @param {[type]} val [an object composed with r, g, b, and a attributes respectively]
* @param {[type]} imgData [image data of the canvas]
*/
function setPixel(i, val, imgData) {
imgData.data[i] = typeof val === 'number' ? val : val.r;
imgData.data[i + 1] = typeof val === 'number' ? val : val.g;
imgData.data[i + 2] = typeof val === 'number' ? val : val.b;
}
灰度
現(xiàn)在,可以開始找輪廓了螟深,點擊 Run 運行 Codepen 上給出的例子谐宙。由于有一定的復雜性,要等一會兒才能在屏幕上看到結果界弧。
灰度在維基百科上的定義如下:
在攝影和計算領域凡蜻,灰度 或者說 灰度 數(shù)字圖像是每個像素值都是單個采樣的圖片,即垢箕,這樣的圖片只攜帶亮度信息划栓。
本節(jié),我們將用兩個方法實現(xiàn)灰度處理:
/**
* [calculateGray: 計算灰度值]
* @param {[type]} pixel [an object composed with r, g, b, and a attributes respectively]
* @return {[Number]} [return a grayscale value]
*/
function calculateGray(pixel) {
return ((0.3 * pixel.r) + (0.59 * pixel.g) + (0.11 * pixel.b));
}
/**
* [grayscale: 為 canvas 處理灰度]
* @param {[type]} canvas [the canvas object]
*/
function grayscale(canvas) {
var ctx = canvas.getContext('2d');
var imgDataCopy = ctx.getImageData(0, 0, canvas.width, canvas.height);
var grayLevel;
runImg(canvas, null, function (current) {
grayLevel = calculateGray(getPixel(current, imgDataCopy));
setPixel(current, grayLevel, imgDataCopy);
});
ctx.putImageData(imgDataCopy, 0, 0);
}
栗子如下:
See the Pen gLOgLM by aleen42 (@aleen42) on CodePen.
高斯模糊
高斯模糊是增加邊緣檢測精度的一個方法条获,也是 Canny 邊緣檢測的第一步忠荞。
/**
* [sumArr: 給定數(shù)組取和]
* @param {[type]} arr [the array]
* @return {[type]} [return the sum value]
*/
function sumArr(arr) {
var result = 0;
arr.map(function(element, index) {
result += (/^\s*function Array/.test(String(element.constructor))) ? sumArr(element) : element;
});
return result;
}
/**
* [generateKernel: 生成高斯模糊算法的核心參數(shù)]
* @param {[type]} sigma [the sigma value]
* @param {[type]} size [the size of the matrix]
* @return {[type]} [description]
*/
function generateKernel(sigma, size) {
var kernel = [];
/** Euler's number rounded of to 3 places */
var E = 2.718;
for (var y = -(size - 1) / 2, i = 0; i < size; y++, i++) {
kernel[i] = [];
for (var x = -(size - 1) / 2, j = 0; j < size; x++, j++) {
/** create kernel round to 3 decimal places */
kernel[i][j] = 1 / (2 * Math.PI * Math.pow(sigma, 2)) * Math.pow(E, -(Math.pow(Math.abs(x), 2) + Math.pow(Math.abs(y), 2)) / (2 * Math.pow(sigma, 2)));
}
}
/** normalize the kernel to make its sum 1 */
var normalize = 1 / sumArr(kernel);
for (var k = 0; k < kernel.length; k++) {
for (var l = 0; l < kernel[k].length; l++) {
kernel[k][l] = Math.round(normalize * kernel[k][l] * 1000) / 1000;
}
}
return kernel;
}
/**
* [gaussianBlur: 對 canvas 對象進行高斯模糊處理]
* @param {[type]} canvas [the canvas object]
* @param {[type]} sigma [the sigma value]
* @param {[type]} size [the size of the matrix]
* @return {[type]} [description]
*/
function gaussianBlur(canvas, sigma, size) {
var ctx = canvas.getContext('2d');
var imgDataCopy = ctx.getImageData(0, 0, canvas.width, canvas.height);
var kernel = generateKernel(sigma, size);
runImg(canvas, size, function (current, neighbors) {
var resultR = 0;
var resultG = 0;
var resultB = 0;
var pixel;
for (var i = 0; i < size; i++) {
for (var j = 0; j < size; j++) {
pixel = getPixel(neighbors[i][j], imgDataCopy);
/** 返回像素值乘以核心值 */
resultR += pixel.r * kernel[i][j];
resultG += pixel.g * kernel[i][j];
resultB += pixel.b * kernel[i][j];
}
}
setPixel(current, {
r: resultR,
g: resultG,
b: resultB
}, imgDataCopy);
});
ctx.putImageData(imgDataCopy, 0, 0);
}
如果你想檢查效果,改變 sigma 和 size 參數(shù)返回演示如下帅掘,
See the Pen LbYWYN by aleen42 (@aleen42) on CodePen.
Canny 梯度
在這步委煤,我們將找到圖片的亮度梯度(G)。在之前修档,我們要得到邊緣檢測器(Roberts碧绞,Prewitt,Sobel等)第一步在水平方向(Gx)和垂直方向(Gy)的衍生值吱窝。我們用的是 Sobel 探測器讥邻。
在處理灰度之前,我們應該導出一個模塊院峡,用于操作像素兴使,我們命名為 Pixel。
(function(exports) {
/** 實際上照激,每個像素有 8 個方向 */
var DIRECTIONS = ['n', 'e', 's', 'w', 'ne', 'nw', 'se', 'sw'];
function Pixel(i, w, h, canvas) {
this.index = i;
this.width = w;
this.height = h;
this.neighbors = [];
this.canvas = canvas;
DIRECTIONS.map(function(d, idx) {
this.neighbors.push(this[d]());
}.bind(this));
}
/**
* 這個對象方便獲取 8 個臨近方向的像素值
* _______________
* | NW | N | NE |
* |____|___|____|
* | W | C | E |
* |____|___|____|
* | SW | S | SE |
* |____|___|____|
* 給定矩陣模型的 index, width and height
**/
Pixel.prototype.n = function() {
/**
* 像素在 canvas 圖片數(shù)據(jù)中是個簡單數(shù)組
* 1 個像素占用 4 個連續(xù)數(shù)組元素
* 等于 r-g-b-a
*/
return (this.index - this.width * 4);
};
Pixel.prototype.e = function() {
return (this.index + 4);
};
Pixel.prototype.s = function() {
return (this.index + this.width * 4);
};
Pixel.prototype.w = function() {
return (this.index - 4);
};
Pixel.prototype.ne = function() {
return (this.index - this.width * 4 + 4);
};
Pixel.prototype.nw = function() {
return (this.index - this.width * 4 - 4);
};
Pixel.prototype.se = function() {
return (this.index + this.width * 4 + 4);
};
Pixel.prototype.sw = function() {
return (this.index + this.width * 4 - 4);
};
Pixel.prototype.r = function() {
return this.canvas[this.index];
};
Pixel.prototype.g = function() {
return this.canvas[this.index + 1];
};;
Pixel.prototype.b = function() {
return this.canvas[this.index + 2];
};
Pixel.prototype.a = function() {
return this.canvas[this.index + 3];
};
Pixel.prototype.isBorder = function() {
return (this.index - (this.width * 4)) < 0 ||
(this.index % (this.width * 4)) === 0 ||
(this.index % (this.width * 4)) === ((this.width * 4) - 4) ||
(this.index + (this.width * 4)) > (this.width * this.height * 4);
};
exports.Pixel = Pixel;
}(this));
用 Pixel 開始實現(xiàn)梯度處理:
function roundDir(deg) {
/** rounds degrees to 4 possible orientations: horizontal, vertical, and 2 diagonals */
var deg = deg < 0 ? deg + 180 : deg;
if ((deg >= 0 && deg <= 22.5) || (deg > 157.5 && deg <= 180)) {
return 0;
} else if (deg > 22.5 && deg <= 67.5) {
return 45;
} else if (deg > 67.5 && deg <= 112.5) {
return 90;
} else if (deg > 112.5 && deg <= 157.5) {
return 135;
}
};
function gradient(canvas, op) {
var ctx = canvas.getContext('2d');
var imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
var imgDataCopy = ctx.getImageData(0, 0, canvas.width, canvas.height);
var dirMap = [];
var gradMap = [];
var SOBEL_X_FILTER = [
[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]
];
var SOBEL_Y_FILTER = [
[1, 2, 1],
[0, 0, 0],
[-1, -2, -1]
];
var ROBERTS_X_FILTER = [
[1, 0],
[0, -1]
];
var ROBERTS_Y_FILTER = [
[0, 1],
[-1, 0]
];
var PREWITT_X_FILTER = [
[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1]
];
var PREWITT_Y_FILTER = [
[-1, -1, -1],
[0, 0, 0],
[1, 1, 1]
];
var OPERATORS = {
'sobel': {
x: SOBEL_X_FILTER,
y: SOBEL_Y_FILTER,
len: SOBEL_X_FILTER.length
},
'roberts': {
x: ROBERTS_X_FILTER,
y: ROBERTS_Y_FILTER,
len: ROBERTS_Y_FILTER.length
},
'prewitt': {
x: PREWITT_X_FILTER,
y: PREWITT_Y_FILTER,
len: PREWITT_Y_FILTER.length
}
};
runImg(canvas, 3, function (current, neighbors) {
var edgeX = 0;
var edgeY = 0;
var pixel = new Pixel(current, imgDataCopy.width, imgDataCopy.height);
if (!pixel.isBorder()) {
for (var i = 0; i < OPERATORS[op].len; i++) {
for (var j = 0; j < OPERATORS[op].len; j++) {
edgeX += imgData.data[neighbors[i][j]] * OPERATORS[op]["x"][i][j];
edgeY += imgData.data[neighbors[i][j]] * OPERATORS[op]["y"][i][j];
}
}
}
dirMap[current] = roundDir(Math.atan2(edgeY, edgeX) * (180 / Math.PI));
gradMap[current] = Math.round(Math.sqrt(edgeX * edgeX + edgeY * edgeY));
setPixel(current, gradMap[current], imgDataCopy);
});
ctx.putImageData(imgDataCopy, 0, 0);
}
樣例如下:
See the Pen aBbpWM by aleen42 (@aleen42) on CodePen.
Canny 非極大值抑制
非極大值抑制應用到 “薄” 邊发魄。梯度計算后,從梯度值中提取的邊緣仍然很模糊俩垃。根據(jù)范式 3欠母,邊緣只能有一個精確值欢策。所以非極大值抑制能夠幫助抑制除了本地極大值之外的其他值,指出亮度值改變最大的位置赏淌。
最后一步是計算 dirMap
和 graphMap
:
function getPixelNeighbors(dir) {
var degrees = {
0: [{ x: 1, y: 2 }, { x: 1, y: 0 }],
45: [{ x: 0, y: 2 }, { x: 2, y: 0 }],
90: [{ x: 0, y: 1 }, { x: 2, y: 1 }],
135: [{ x: 0, y: 0 }, { x: 2, y: 2 }]
};
return degrees[dir];
}
function nonMaximumSuppress(canvas, dirMap, gradMap) {
var ctx = canvas.getContext('2d');
var imgDataCopy = ctx.getImageData(0, 0, canvas.width, canvas.height);
runImg(canvas, 3, function(current, neighbors) {
var pixNeighbors = getPixelNeighbors(dirMap[current]);
/** pixel neighbors to compare */
var pix1 = gradMap[neighbors[pixNeighbors[0].x][pixNeighbors[0].y]];
var pix2 = gradMap[neighbors[pixNeighbors[1].x][pixNeighbors[1].y]];
if (pix1 > gradMap[current] ||
pix2 > gradMap[current] ||
(pix2 === gradMap[current] &&
pix1 < gradMap[current])) {
setPixel(current, 0, imgDataCopy);
}
});
ctx.putImageData(imgDataCopy, 0, 0);
}
抑制之后踩寇,看起來比以前效果要好:
See the Pen jVOBNe by aleen42 (@aleen42) on CodePen.
Canny 磁滯
無論如何,這個所謂的 “弱” 邊還需要進一步加工六水。Canny 磁滯是 Canny 邊緣檢測的改進方法俺孙。
function createHistogram(canvas) {
var histogram = {
g: []
};
var size = 256;
var total = 0;
var ctx = canvas.getContext('2d');
var imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
while (size--) {
histogram.g[size] = 0;
}
runImg(canvas, null, function(i) {
histogram.g[imgData.data[i]]++;
total++;
});
histogram.length = total;
return histogram;
};
function calcBetweenClassVariance(weight1, mean1, weight2, mean2) {
return weight1 * weight2 * (mean1 - mean2) * (mean1 - mean2);
};
function calcWeight(histogram, s, e) {
var total = histogram.reduce(function(i, j) {
return i + j;
}, 0);
var partHist = (s === e) ? [histogram[s]] : histogram.slice(s, e);
var part = partHist.reduce(function(i, j) {
return i + j;
}, 0);
return parseFloat(part, 10) / total;
};
function calcMean(histogram, s, e) {
var partHist = (s === e) ? [histogram[s]] : histogram.slice(s, e);
var val = 0;
var total = 0;
partHist.forEach(function(el, i) {
val += ((s + i) * el);
total += el;
});
return parseFloat(val, 10) / total;
};
function fastOtsu(canvas) {
var histogram = createHistogram(canvas);
var start = 0;
var end = histogram.g.length - 1;
var leftWeight;
var rightWeight;
var leftMean;
var rightMean;
var betweenClassVariances = [];
var max = -Infinity;
var threshold;
histogram.g.forEach(function(el, i) {
leftWeight = calcWeight(histogram.g, start, i);
rightWeight = calcWeight(histogram.g, i, end + 1);
leftMean = calcMean(histogram.g, start, i);
rightMean = calcMean(histogram.g, i, end + 1);
betweenClassVariances[i] = calcBetweenClassVariance(leftWeight, leftMean, rightWeight, rightMean);
if (betweenClassVariances[i] > max) {
max = betweenClassVariances[i];
threshold = i;
}
});
return threshold;
};
function getEdgeNeighbors(i, imgData, threshold, includedEdges) {
var neighbors = [];
var pixel = new Pixel(i, imgData.width, imgData.height);
for (var j = 0; j < pixel.neighbors.length; j++) {
if (imgData.data[pixel.neighbors[j]] >= threshold && (includedEdges === undefined || includedEdges.indexOf(pixel.neighbors[j]) === -1)) {
neighbors.push(pixel.neighbors[j]);
}
}
return neighbors;
}
function _traverseEdge(current, imgData, threshold, traversed) {
/**
* traverses the current pixel until a length has been reached
* initialize the group from the current pixel's perspective
*/
var group = [current];
/** pass the traversed group to the getEdgeNeighbors so that it will not include those anymore */
var neighbors = getEdgeNeighbors(current, imgData, threshold, traversed);
for (var i = 0; i < neighbors.length; i++) {
/** recursively get the other edges connected */
group = group.concat(_traverseEdge(neighbors[i], imgData, threshold, traversed.concat(group)));
}
/** if the pixel group is not above max length, it will return the pixels included in that small pixel group */
return group;
}
function hysteresis(canvas) {
var ctx = canvas.getContext('2d');
var imgDataCopy = ctx.getImageData(0, 0, canvas.width, canvas.height);
/** where real edges will be stored with the 1st pass */
var realEdges = [];
/** high threshold value */
var t1 = fastOtsu(canvas);
/** low threshold value */
var t2 = t1 / 2;
/** first pass */
runImg(canvas, null, function(current) {
if (imgDataCopy.data[current] > t1 && realEdges[current] === undefined) {
/** accept as a definite edge */
var group = _traverseEdge(current, imgDataCopy, t2, []);
for (var i = 0; i < group.length; i++) {
realEdges[group[i]] = true;
}
}
});
/** second pass */
runImg(canvas, null, function(current) {
if (realEdges[current] === undefined) {
setPixel(current, 0, imgDataCopy);
} else {
setPixel(current, 255, imgDataCopy);
}
});
ctx.putImageData(imgDataCopy, 0, 0);
}
從圖中刪除 “弱” 邊之后是什么樣的呢?
See the Pen RowpLx by aleen42 (@aleen42) on CodePen.
哇掷贾,看起來更完美了睛榄。
掃描
這幅圖只有兩種像素:0 和 255,可以通過掃描每個像素生成點路徑想帅。算法描述如下:
- 循環(huán)獲取像素值, 檢測是否被標記為255值.
- 匹配之后场靴,找出生成最長路徑的方向。(當一條路徑是由自身組成的港准,每個像素都會被標記旨剥,當一條路徑的點有超過一個值,就是一條真實路徑浅缸,6 ~ 10轨帜。)
掃描之后,提取 SVG 的路徑數(shù)據(jù)衩椒,當然你還可以繪制路徑蚌父。
小結
本文詳細地討論了如何用 JavaScript 繪圖,不管是 SVG 文件還是其他類型圖片毛萌,比如 PNG苟弛、JPG 和 GIF。核心思想是轉換特定格式到路徑數(shù)據(jù)阁将。一旦抽離出這樣的數(shù)據(jù)膏秫,我們還可以模進行模擬繪圖。
- 直接繪制 SVG 文件中的
path
元素冀痕。 - 如果是其他元素荔睹,例如
rect
狸演,需要先轉換成path
言蛇。 - 使用 Canny 輪廓檢測算法檢測位圖中的輪廓,這樣才可以繪制.
參考文檔
- [1] "光線繪圖動畫", 2016
- [2] "位圖輪廓查找", 2016
- [3] "繪制一個 SVG 文件", 2016
- [4] "SVG 文件繪制的校準參數(shù)", 2016
- [5] "轉換所有形狀/原始形狀到 SVG 的路徑元素", 2016
- [6] "輪廓", 2015
- [7] "Canny 邊緣檢測器", Wikipedia, 2016