基于Canvas实现的高斯模糊

本文原创作者:字节跳动 iNahoo

---------------------------

自从扁平化流行起来之后,高斯模糊效果渐渐变成了视觉很喜欢用的一种表现形式,我们的视觉小姐姐也特别喜欢。为了满足她,踩了无穷无尽的坑之后,最后只能掏出Canvas来了。

没有什么视觉需求是Canvas解决不了的,如果有,再盖一层Canvas

解决痛点

  1. CSS模糊 和 大面积 transform 混用时,会导致的性能问题 ( 卡 )

  2. CSS(svg)模糊 在图片边界的表现不够优秀,比如白边

  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 内的加权均值

  • 输出

  • 边界处理

    观察系统的高斯模糊效果,边界总是半透明的。为了强调这一效果,我们在原图的背后增加一块等大小的红色背景。

    可以很清楚的看到边界的虚边,我推测浏览器在实现时,在边界处增加了 alpha=0 的点来补齐计算。
    对此,我的解决方案是:仅计算存在的点的权重

    算法实现
    const gaussBlur = function (imgData, radius) {
    
        radius *= 3; //不知为什么,我的模糊半径是 css中 filter:bulr 值的三倍时效果才一致。莫非是当时的测试机是@3x?
    
        //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,我要报警了!

    优化算法

    目前的算法,复杂度大约是 wh * (2r)^2

    之后我去搜了搜 大神代码,发现他们是先进行一轮X轴方向模糊,再进行一轮Y轴方向模糊,复杂度只有 2wh * 2r , 一下少了好多运算量。

    我们也来试试。

    [ 效果立竿见影 ]

    以我的数学水平,并不能证明两者是等效的,但是从视觉上来看是一致的,为什么可以这样优化,期望大神赐教。

    使用优化

    即使已经这样进行了优化,依然会有 500ms 的计算时长,对于移动端来说通常是不可接受的。

    从算法上可以看出来,运算量由三个方面来决定:图片宽w、高h,模糊半径r。
    这样就能对我们的几个常见使用场景进行优化

    1. 大尺寸图片

    例如一张900x600的图片,需要输出一张300x200@2x

    可以将图片先缩放到300x200再计算模糊

    2. 大半径模糊

    例如一张900x600的图片,需要模糊半径150,需要输出一张300x200@2x的图

    这样的图可以说是细节全失,通常视觉只Care成图的大概色彩范围,我们可以用一些粗暴的方法。

    1. 等比例计算,把图片变成 6x4 r=1 ,

    2. 计算模糊,输出 6x4 的图片

    3. 使用css拉伸到 300x200

    实现

    说白了优化手段就是一招缩小射线,我们抽象一个参数: 缩小倍率 shrink
    对于一张图片的模糊过程可能会有超过一帧的计算量,所以我们把它变成异步的,方便未来迁移到 WebAssembly 或者 Worker 中。

    /**
     * @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+

  • Cross-Origin in 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;
        })
    };
    

    ╮(╯▽╰)╭

    参考资料

    • 《高斯模糊的算法》 - 阮一峰的网络日志

    • 高斯模糊算法(gaussian)

    • 欢迎关注“玄说-前端”微信公众号

    • ------------福利时间--------------

    • 本次赠书活动,赠送如下精选书籍:

    • 1) 被称为通关 大厂面试 第一奇书的《你不知道的JavaScript》三卷。所有Js面试难题,都在此书范围内。本次赠书9本(3套)

    • 2)js函数式编程是逼格提升利器,要高薪必备技能。本次活动赠送《JavaScript函数式编程指南》5本。

    • 活动参与办法:

    • 订阅号回复(切记,不是本篇文章下留言):pr。

    • 我们会统计最终参与名单,随机抽取获奖者。

    • 活动时间:

    • 2019年12月16日--2019年12月21日。

    • 2019年12月23日公布获奖名单。

    • --------

    • 扫描下方二维码,加”助理“好友,回复”前端“,进入“玄说-前端” 微信群,可以参加额外的群内红包抽奖赠书活动。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值