閱讀975 返回首頁    go 汽車大全


H5中基於Canvas實現的高斯模煳

自從扁平化流行起來之後,高斯模煳效果漸漸變成了視覺很喜歡用的一種表現形式,我們的視覺小姐姐也特別喜歡。為了滿足她,踩了無窮無盡的坑之後,最後隻能掏出Canvas來了。

沒有什麼視覺需求是Canvas解決不了的,如果有,再蓋一層Canvas —— 奈帆斯基

解決痛點

  1. CSS模煳 和 大麵積transform 混用時,會導致的性能問題 ( 卡 )
  2. CSS模煳 在圖片邊界的表現不夠優秀
  3. iOS下高像素的高斯模煳會出現 奇怪的現象 ( 突然顏色大變 )
  4. 一套解決方案,不再需要 svg+多種css兼容 判環境應用

理論原理

對算法部分無愛的弟兄們直接跳過本節也沒關係的。

理論

模煳的效果相信大家都不陌生,實際上就是一種加權平均算法。
高斯模煳( Gaussian Blur ) 就是以高斯分布作為權重的平均算法。高斯分布長下麵這個樣子。

[ 一維高斯分布 ]

圖片有x,y兩個維度,所以在平均的時候應該使用二維高斯分布

[ 二維高斯分布 ]

基本算法

  1. 輸入 圖片Img , 模煳半徑radius
  2. radius 計算出 高斯矩陣 gaussMatrix 避免重複計算
  3. 遍曆每一個像素
    • 提取當前像素[x,y]{r,g,b,a}
    • 求範圍 [ x ± radius , y ± radius ] 內的 {r,g,b,a} 各自在 gaussMatrix 內的加權均值
  4. 輸出

邊界的處理

觀察係統的高斯模煳效果,邊界總是半透明的。推測是在邊界處增加 alpha=0 的點補齊計算。

[ css模煳 - 紅色部分為初始邊界 ]

嗯,這個效果也算 痛點 之一吧。我的解決方案是:**僅計算存在的點的權重**

算法實現

const gaussBlur = function (imgData, radius) {

    radius *= 3; //不知為什麼,我的模煳半徑是 css中 filter:bulr 值的三倍時效果才一致。

    //Copy圖片內容
    const pixes = new Uint8ClampedArray(imgData.data);
    const width = imgData.width;
    const height = imgData.height;

    let gaussSum = 0,
        x, y,
        r, g, b, a, i;

    //模煳半徑取整
    radius = Math.floor(radius);
    //sigma越小中心點權重越高, sigma越大越接近平均模煳
    const sigma = radius / 3;
    //兩個分布無相關性, 為了各方向上權重分布一致
    const Ror = 0;

    const L = radius * 2 + 1;  //矩陣寬度

    const Ror2 = Ror * Ror;
    const s2 = sigma * sigma;
    const c1 = 1 / (  2 * Math.PI * s2 * Math.sqrt(1 - Ror * Ror));
    const c2 = -1 / (2 * (1 - Ror2));

    //定義高斯矩陣 , 存儲在一維數組中
    const gaussMatrix = [];

    //根據 xy 計算 index
    gaussMatrix.getIndex = (x, y)=> {
        return (x + radius) + (y + radius) * L;
    }
    //根據 xy 獲取權重
    gaussMatrix.getWeight = (x, y)=> {
        return gaussMatrix[gaussMatrix.getIndex(x, y)];
    }
    //根據 index 獲取 x 偏移
    gaussMatrix.getX = (index)=> {
        return index % L - radius;
    }
    //根據 index 獲取 y 偏移
    gaussMatrix.getY = (index)=> {
        return Math.floor(index / L) - radius;
    }

    //覆寫forEach , 方便遍曆
    gaussMatrix.forEach = (f)=> {
        gaussMatrix.map((w, i)=> {
            f(w, gaussMatrix.getX(i), gaussMatrix.getY(i))
        })
    }

    //生成高斯矩陣
    for (y = -radius; y <= radius; y++) {
        for (x = -radius; x <= radius; x++) {
            let i = gaussMatrix.getIndex(x, y);
            g = c1 * Math.exp(c2 * (x * x + 2 * Ror * x * y + y * y) / s2);
            gaussMatrix[i] = g;
        }
    }

    //快捷獲取像素點數據
    const getPixel = (x, y)=> {
        if (x < 0 || x >= width || y < 0 || y >= height) {
            return null;
        }
        let p = (x + y * width) * 4;
        return pixes.subarray(p, p + 4);
    }

    //遍曆圖像上的每個點
    i = 0;
    for (y = 0; y < height; y++) {
        for (x = 0; x < width; x++) {

            //重置 r g b a Sum
            r = g = b = a = 0;
            gaussSum = 0;

            //遍曆模煳半徑內的其他點
            gaussMatrix.forEach((w, dx, dy)=> {
                let p = getPixel(x + dx, y + dy);
                if (!p)return;

                //求加權和
                r += p[0] * w;
                g += p[1] * w;
                b += p[2] * w;
                a += p[3] * w;
                gaussSum += w;
            });

            //寫回imgData
            imgData.data.set([r, g, b, a].map(v=>v / gaussSum), i);

            //遍曆下一個點
            i += 4;
        }
    }

    return imgData;
};

寫完了實現的我,迫不及待的試了試

[ 效果拔群! 無與倫比! 掌聲呢?!!! ]

一般來說寫到這裏,就算功成名就了,不過我瞥了一眼控製台...

足足算了21秒,這可是我心愛的 MacPro,我要報警了!

優化算法

目前的算法,複雜度大約是 w * h * (2r)^2

之後我去搜了搜 大神代碼,發現他們是先進行一輪X軸方向模煳,再進行一輪Y軸方向模煳,複雜度隻有 2 * w * h * 2r , 一下少了好多運算量。

我們也來試試。

[ 效果立竿見影 ]

以我的數學水平,並不能證明兩者是等效的,但是從視覺上來看是一致的,為什麼可以這樣優化,期望大神賜教。

使用優化

從算法上可以看出來,運算量由三個方麵來決定:圖片寬w、高h,模煳半徑r。
這樣就能對我們的幾個常見使用場景進行優化

1. 大尺寸圖片

例如一張900x600的圖片,需要輸出一張300x200@2x

可以將圖片先縮放到300x200再計算模煳

2. 大半徑模煳

例如一張900x600的圖片,需要模煳半徑150,需要輸出一張300x200@2x的圖

這樣的圖可以說是細節全失,通常視覺隻Care成圖的大概色彩範圍,我們可以用一些粗暴的方法。

  1. 等比例計算,把圖片變成 6x4 r=1
  2. 計算模煳,輸出 6x4 的圖片
  3. 使用css拉伸到 300x200

實現

說白了優化手段就是一招縮小射線,我們抽象一個參數: 縮小倍率 shrink

/**
 * @public
 * 暴露的異步模煳方法
 * ---------------------
 * @param URL       圖片地址,需要跨域支持
 * @param r         模煳半徑 {Int}
 * @param shrink    縮小比率 {Number}
 * @return {Promise}
 */
export const blur = (URL, r, shrink = 1)=> {
    return new Promise((resolve, reject)=> {

        const IMG = new Image();
        IMG.crossOrigin = '*'; //需要圖片跨域支持

        IMG.onload = function () {
            const Canvas = document.createElement('CANVAS'); //大量使用可考慮隻創建一次

            let w = IMG.width, h = IMG.height;

            //縮小比例不為1時 , 重新計算寬高比
            if (shrink !== 1) {
                w = Math.ceil(w / shrink);
                h = Math.ceil(h / shrink);
                r = Math.ceil(r / shrink);
            }

            //因為懶, 就全Try了, 實際上隻 Try跨域錯誤 即可
            try {
                //設置Canvas寬高,獲取上下文
                Canvas.width = w;
                Canvas.height = h;
                let ctx = Canvas.getContext('2d');

                ctx.drawImage(IMG, 0, 0, w, h);

                //提取圖片信息
                let d = ctx.getImageData(0, 0, w, h);

                //進行高斯模煳
                let gd = gaussBlur(d, r, 0);

                //繪製模煳圖像
                ctx.putImageData(gd, 0, 0);

                resolve(Canvas.toDataURL());
            } catch (e) {
                reject(e);
            }
        };
        IMG.src = URL;
    })
};

以一張 640x426 的圖片,輸出{ 300x200,r=10 }為例:
對比
1. 原尺寸模煳
2. 縮小到1/10進行模煳


首先要明確的是,**在縮小情況下兩種算法並不等價**。小圖放大的模煳效果取決於瀏覽器本身的算法實現。最終視覺上**效果差別不顯著,完全可以使用**。

麵對形形色色的尺寸

考慮到來自服務端的圖片可能有各種神奇的尺寸,而通常輸出是一個確定的尺寸。

在這樣的情況下,縮小比例會產生一些冗餘,所以更適合另一個【鎖定輸出寬高的實現】。

/**
 * @public
 * 暴露的異步模煳方法
 * ---------------------
 * @param URL       圖片地址,需要跨域支持
 * @param r         模煳半徑 {Int}
 * @param w         輸出寬度 {Number}
 * @param h         輸出高度 {Number}
 * @return {Promise}
 */
export const blurWH = (URL, r, w ,h)=> {
    return new Promise((resolve, reject)=> {

        const IMG = new Image();
        IMG.crossOrigin = '*'; //需要圖片跨域支持

        IMG.onload = function () {
            const Canvas = document.createElement('CANVAS'); //大量使用可考慮隻創建一次

            //鎖定輸出寬高之後, 就不需要Care 原圖有多寬多高了
            //let w = IMG.width, h = IMG.height;

            //因為懶, 就全Try了, 實際上隻 Try跨域錯誤 即可
            try {
                //設置Canvas寬高,獲取上下文
                Canvas.width = w;
                Canvas.height = h;
                let ctx = Canvas.getContext('2d');

                ctx.drawImage(IMG, 0, 0, w, h);

                //提取圖片信息
                let d = ctx.getImageData(0, 0, w, h);

                //進行高斯模煳
                let gd = gaussBlur(d, r, 0);

                //繪製模煳圖像
                ctx.putImageData(gd, 0, 0);

                resolve(Canvas.toDataURL());
            } catch (e) {
                reject(e);
            }
        };
        IMG.src = URL;
    })
};

總結

V8對連續執行的代碼有靜態優化,所以文中所列時間大家不要較真,看個數量級就好 ╮(╯▽╰)╭

兼容性

  1. Uint8ClampedArray Can I use?

    • Android 4+
    • iOS safari 7.1+
  2. Cross-Origin in <cavnas> Can I use?

    • Android 4.4 +
    • iOS safari 7.1+

完整實現


/**
 * @fileOverview
 * 高斯模煳
 * @author iNahoo
 * @since 2017/5/8.
 */
"use strict";

const gaussBlur = function (imgData, radius) {

    radius *= 3;    //不知為什麼,我的模煳半徑是 css中 filter:bulr 值的三倍時效果才一致。

    //Copy圖片內容
    let pixes = new Uint8ClampedArray(imgData.data);
    const width = imgData.width;
    const height = imgData.height;
    let gaussMatrix = [],
        gaussSum,
        x, y,
        r, g, b, a,
        i, j, k,
        w;

    radius = Math.floor(radius);
    const sigma = radius / 3;

    a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
    b = -1 / (2 * sigma * sigma);

    //生成高斯矩陣
    for (i = -radius; i <= radius; i++) {
        gaussMatrix.push(a * Math.exp(b * i * i));
    }

    //x 方向一維高斯運算
    for (y = 0; y < height; y++) {
        for (x = 0; x < width; x++) {
            r = g = b = a = gaussSum = 0;
            for (j = -radius; j <= radius; j++) {
                k = x + j;
                if (k >= 0 && k < width) {
                    i = (y * width + k) * 4;
                    w = gaussMatrix[j + radius];

                    r += pixes[i] * w;
                    g += pixes[i + 1] * w;
                    b += pixes[i + 2] * w;
                    a += pixes[i + 3] * w;

                    gaussSum += w;
                }
            }

            i = (y * width + x) * 4;
            //計算加權均值
            imgData.data.set([r, g, b, a].map(v=>v / gaussSum), i);
        }
    }

    pixes.set(imgData.data);

    //y 方向一維高斯運算
    for (x = 0; x < width; x++) {
        for (y = 0; y < height; y++) {
            r = g = b = a = gaussSum = 0;
            for (j = -radius; j <= radius; j++) {
                k = y + j;

                if (k >= 0 && k < height) {
                    i = (k * width + x) * 4;
                    w = gaussMatrix[j + radius];

                    r += pixes[i] * w;
                    g += pixes[i + 1] * w;
                    b += pixes[i + 2] * w;
                    a += pixes[i + 3] * w;

                    gaussSum += w;
                }
            }
            i = (y * width + x) * 4;
            imgData.data.set([r, g, b, a].map(v=>v / gaussSum), i);
        }
    }

    return imgData;
};

/**
 * @public
 * 暴露的異步模煳方法
 * ---------------------
 * @param URL       圖片地址,需要跨域支持
 * @param r         模煳半徑 {Int}
 * @param shrink    縮小比率 {Number}
 * @return {Promise}
 */
export const blur = (URL, r, shrink = 1)=> {
    return new Promise((resolve, reject)=> {

        const IMG = new Image();
        IMG.crossOrigin = '*'; //需要圖片跨域支持

        IMG.onload = function () {
            const Canvas = document.createElement('CANVAS'); //大量使用可考慮隻創建一次

            let w = IMG.width, h = IMG.height;

            //縮小比例不為1時 , 重新計算寬高比
            if (shrink !== 1) {
                w = Math.ceil(w / shrink);
                h = Math.ceil(h / shrink);
                r = Math.ceil(r / shrink);
            }

            //因為懶, 就全Try了, 實際上隻 Try跨域錯誤 即可
            try {
                //設置Canvas寬高,獲取上下文
                Canvas.width = w;
                Canvas.height = h;
                let ctx = Canvas.getContext('2d');

                ctx.drawImage(IMG, 0, 0, w, h);

                //提取圖片信息
                let d = ctx.getImageData(0, 0, w, h);

                //進行高斯模煳
                let gd = gaussBlur(d, r, 0);

                //繪製模煳圖像
                ctx.putImageData(gd, 0, 0);

                resolve(Canvas.toDataURL());
            } catch (e) {
                reject(e);
            }
        };
        IMG.src = URL;
    })
};

/**
 * @public
 * 暴露的異步模煳方法
 * ---------------------
 * @param URL       圖片地址,需要跨域支持
 * @param r         模煳半徑 {Int}
 * @param w         輸出寬度 {Number}
 * @param h         輸出高度 {Number}
 * @return {Promise}
 */
export const blurWH = (URL, r, w, h)=> {
    return new Promise((resolve, reject)=> {

        const IMG = new Image();
        IMG.crossOrigin = '*'; //需要圖片跨域支持

        IMG.onload = function () {
            const Canvas = document.createElement('CANVAS'); //大量使用可考慮隻創建一次

            //鎖定輸出寬高之後, 就不需要Care 原圖有多寬多高了
            //let w = IMG.width, h = IMG.height;

            //因為懶, 就全Try了, 實際上隻 Try跨域錯誤 即可
            try {
                //設置Canvas寬高,獲取上下文
                Canvas.width = w;
                Canvas.height = h;
                let ctx = Canvas.getContext('2d');

                ctx.drawImage(IMG, 0, 0, w, h);

                //提取圖片信息
                let d = ctx.getImageData(0, 0, w, h);

                //進行高斯模煳
                let gd = gaussBlur(d, r, 0);

                //繪製模煳圖像
                ctx.putImageData(gd, 0, 0);

                resolve(Canvas.toDataURL());
            } catch (e) {
                reject(e);
            }
        };
        IMG.src = URL;
    })
};

廣告

知道我為什麼不放Demo嘛?

我大A工作室開發的 《淘票票專業版》 已經上線啦!

想看demo的歡迎下載APP,瀏覽各個影片詳情的時候,順便瞅一眼頭部的海報背景,那我是逝去的頭f... 啊不,是我親手模煳的圖片。

╮(╯▽╰)╭

參考資料

最後更新:2017-07-24 11:03:30

  上一篇:go  阿裏雲前端周刊 - 第 16 期
  下一篇:go  【 H5踩坑 】Dom變更引起的 touchend 不觸發