如何用 JavaScript 作畫

圖 1.1 簡單預覽

因為我司給我一個在瀏覽器中以編程方式來實現(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ā)項目的時候有一個問題是到底需要重點關注什么词爬。在一個復合路徑中,mM 完全不一樣权均,必須要有至少一個 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 所謂面板

圖 2.1 展示了一個高亮工作區(qū)域,我叫它 面板居凶。在這個面板上虫给,你可以進行拖放,拖拽侠碧,調整尺寸或者移動操作抹估。實際上,面板里包含了一個能滿足你需求的 Canvas 對象弄兜。只需要把 SVG 文件(圖 2.2)拖放到面板里药蜻,就可以在屏幕上重繪,結果如圖 2.3:

圖 2.2 繪制的 SVG 文件

富含中國元素的美麗 logo 就生成啦

圖 2.3 渲染圖形

除了圖形操作替饿,其他 SVG 屬性也會影響路徑數(shù)據(jù)语泽,比如 widthheightviewBox视卢。

<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 屬性的 xy 值會裁切圖形(如圖 2.4)。所以鳞芙,我們需要從初始點值中去掉這部分值眷柔。

圖 2.4 裁切圖形

我只需要邊緣點的最大值和最小值。舉個栗子原朝,如果點集的位置在圖形之外闯割,我就改變 xy,甚至全部改變竿拆,重寫到圖形的邊上宙拉。

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牛郑、polylinecircle 等其他元素定義的形狀之前敬鬓,應該先轉換成路徑淹朋。本節(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诊杆,x2y2

很簡單何陆,我們可以這么計算:

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晨汹,ywidthheight贷盲。

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欠母,邊緣只能有一個精確值欢策。所以非極大值抑制能夠幫助抑制除了本地極大值之外的其他值,指出亮度值改變最大的位置赏淌。

最后一步是計算 dirMapgraphMap

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 輪廓檢測算法檢測位圖中的輪廓,這樣才可以繪制.

參考文檔

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末宵距,一起剝皮案震驚了整個濱河市腊尚,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌满哪,老刑警劉巖婿斥,帶你破解...
    沈念sama閱讀 219,539評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件劝篷,死亡現(xiàn)場離奇詭異,居然都是意外死亡民宿,警方通過查閱死者的電腦和手機娇妓,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,594評論 3 396
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來活鹰,“玉大人哈恰,你說我怎么就攤上這事≈救海” “怎么了着绷?”我有些...
    開封第一講書人閱讀 165,871評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長锌云。 經常有香客問我荠医,道長,這世上最難降的妖魔是什么桑涎? 我笑而不...
    開封第一講書人閱讀 58,963評論 1 295
  • 正文 為了忘掉前任彬向,我火速辦了婚禮,結果婚禮上石洗,老公的妹妹穿的比我還像新娘幢泼。我一直安慰自己,他們只是感情好讲衫,可當我...
    茶點故事閱讀 67,984評論 6 393
  • 文/花漫 我一把揭開白布缕棵。 她就那樣靜靜地躺著,像睡著了一般涉兽。 火紅的嫁衣襯著肌膚如雪招驴。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,763評論 1 307
  • 那天枷畏,我揣著相機與錄音别厘,去河邊找鬼。 笑死拥诡,一個胖子當著我的面吹牛触趴,可吹牛的內容都是我干的。 我是一名探鬼主播渴肉,決...
    沈念sama閱讀 40,468評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼冗懦,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了仇祭?” 一聲冷哼從身側響起披蕉,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后没讲,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體眯娱,經...
    沈念sama閱讀 45,850評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,002評論 3 338
  • 正文 我和宋清朗相戀三年爬凑,在試婚紗的時候發(fā)現(xiàn)自己被綠了徙缴。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,144評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡嘁信,死狀恐怖娜搂,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情吱抚,我是刑警寧澤百宇,帶...
    沈念sama閱讀 35,823評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站秘豹,受9級特大地震影響携御,放射性物質發(fā)生泄漏。R本人自食惡果不足惜既绕,卻給世界環(huán)境...
    茶點故事閱讀 41,483評論 3 331
  • 文/蒙蒙 一啄刹、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧凄贩,春花似錦誓军、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,026評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至椒丧,卻和暖如春壹甥,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背壶熏。 一陣腳步聲響...
    開封第一講書人閱讀 33,150評論 1 272
  • 我被黑心中介騙來泰國打工句柠, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人棒假。 一個月前我還...
    沈念sama閱讀 48,415評論 3 373
  • 正文 我出身青樓溯职,卻偏偏與公主長得像,于是被迫代替她去往敵國和親帽哑。 傳聞我的和親對象是個殘疾皇子谜酒,可洞房花燭夜當晚...
    茶點故事閱讀 45,092評論 2 355

推薦閱讀更多精彩內容