惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
L
Lohrmann on Cybersecurity
aimingoo的专栏
aimingoo的专栏
V
V2EX
S
Security Affairs
T
Threatpost
C
CXSECURITY Database RSS Feed - CXSecurity.com
IT之家
IT之家
J
Java Code Geeks
The Register - Security
The Register - Security
U
Unit 42
C
CERT Recently Published Vulnerability Notes
月光博客
月光博客
A
About on SuperTechFans
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
The Blog of Author Tim Ferriss
Cisco Talos Blog
Cisco Talos Blog
Project Zero
Project Zero
S
Schneier on Security
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
D
DataBreaches.Net
博客园 - 司徒正美
V
Vulnerabilities – Threatpost
T
Tor Project blog
Security Latest
Security Latest
T
The Exploit Database - CXSecurity.com
T
Threat Research - Cisco Blogs
Scott Helme
Scott Helme
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
M
MIT News - Artificial intelligence
云风的 BLOG
云风的 BLOG
小众软件
小众软件
L
LangChain Blog
Attack and Defense Labs
Attack and Defense Labs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
P
Palo Alto Networks Blog
A
Arctic Wolf
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
C
Cyber Attacks, Cyber Crime and Cyber Security
博客园 - 叶小钗
D
Darknet – Hacking Tools, Hacker News & Cyber Security
L
LINUX DO - 最新话题
MongoDB | Blog
MongoDB | Blog
Webroot Blog
Webroot Blog
H
Hacker News: Front Page
Know Your Adversary
Know Your Adversary
Spread Privacy
Spread Privacy
AWS News Blog
AWS News Blog
Engineering at Meta
Engineering at Meta

云游君的小站

开源项目的「死与新生」 没有红包封面,但是 AI 春联…… 家·烟花·红包封面 云乐坊工作室的成立与缘起与有关的话 聊聊四元数与万向锁及其实际意义 从零开始写一个 Web Component - GitHub Corners 旋转吧!徽章! 又拍云—年轻人的第一个 CDN 云空调,便携小空调 char-dust 一个图片转字符画的 npm 包与示例站点 nnrm - 一个极简的 npm/yarn registry 切换管理器 做猪呢,最重要的是开心 从 Vite 与 Vue 开始的 D3 数据可视化之旅 如何找到你朋友的社交账号 如何从 Valine 迁移至 Disqus ADV 游戏引擎计划 关于我打算当老师并报考教师资格证这件事 Strato - Hexo 主题「Yun」版本宣传 PV 我与小镇与家 el-bot & mirai-ts 制作笔记 第三方评论系统之我见 人生不是一场马拉松 👴就是二次元,故尝试证明。 教你如何从零开始搭建一个属于自己的网站 使用 CDN 加速你的 GitHub Pages 网站 使用 CCDIK 或 FABRIK 实现逆向动力学 【转】三维文件格式知多少 【译】30 分钟学会 LaTeX LaTeX 使用笔记 在 macOS 下配置 OpenGL 路漫漫其修远兮 CSS 陷阱 Blender 学习笔记 Everyday Auto Xuexiqiangguo 酷 Q 使用笔记 Deepin 使用笔记 塞翁失马焉知非福 hexo-theme-yun 制作笔记 写给可爱的学弟学妹们 北线无战事 让百度收录你的 GitHub Pages 博客 搭建自己的 Minecraft 服务器 导出「你的日记」与导入「一本日记」 从来如此 卷土重来未可知 恋爱证明记 微信与象棋 关于部落冲突的二三事儿(番外)-端午事变 Travis-CI 持续集成部署 Vue 中 canvas 的自适应 大家一起吃火锅吧 Parcel.js + Vue 搭建笔记 Vue 学习笔记 【动画电影】《声之形》观后感 VS Code 配置笔记 仓生 YunYouJun/give-me-money 永远相信美好的事情即将发生 鱼与熊掌 人生本该如此。 Discourse 新一代开源论坛 Hexo 静态博客搭建笔记 Coding WebIDE 云端开发实验室 npm & yarn 常用包与命令 像夏花一样绚烂 【番剧】《月色真美》(老夫的少女心啊) 临摹一只智障女神∠( ᐛ 」∠)_ 南京之旅-记10th计算机设计大赛 Git 学习笔记 Laravel 使用笔记 Wordpress 的实用插件与主题推荐 【定格动画】交换/EXchange 我与幻星的故事 来自某夏令营的回忆 水彩原创头像尝试 云游君表情包制作计划 小说连载初尝试-《阿庫娅的星球》 仓鼠 回声 【分享】网页背景效果插件 Canvas-Nest.js 【花开物语|MAD】幹物女(WeiWei)-Bonboru! 太空 关于部落冲突的二三事儿-2 关于部落冲突的二三事儿-1 关于部落冲突的二三事儿-序
写写高斯模糊——从 CSS 模糊滤镜的白边说起
☁️ · 2020-10-18 · via 云游君的小站

前言 ​

通常我们可以很容易地使用 PhotoShop 中的「高斯模糊」来模糊图片。

这也是一种很好的艺术效果,苹果惯用的毛玻璃效果本质便是高斯模糊,而我们将图片模糊后作为网站背景,既减小了图片的体积,也能别有一番风味。(譬如咱站点的背景也是高斯模糊后的产物。)

Gaussian Blur | Photoshop

而 CSS3 提供了滤镜 filter/backdrop-filter,其中的模糊功能同样也是高斯模糊。(Canvas 中的 filter 亦是如此。)

只是当我们为图片背景添加 CSS 滤镜时,便会出现不和谐的白边。

虽然有一些奇技淫巧(比如放大图片再截去模糊的边)去解决,但皆治标不治本,我们不妨借此机会探究一下高斯模糊,并尝试自己实现它。

正文多图预警

简介 ​

高斯模糊看一下维基百科便能了解个一二。不过中文维基百科相比英文百科漏了几条简介。

高斯(Carl Friedrich Gauss),没错,就是我们平常知道的那位高斯,是历史上最为知名、最为重要的数学家之一。而高斯模糊本身并非由它提出,而是这个算法本身使用了高斯分布(也就是概率论中的正态分布)。

f(x)=1σ2πe−(x−μ)22σ2{\displaystyle f(x)={\frac {1}{\sigma {\sqrt {2\pi }}}}e^{-{\frac {\left(x-\mu \right)^{2}}{2\sigma ^{2}}}}}

看到公式不要怕,我们只要会用它。

不过,高斯分布也并非由高斯提出,其最先由德国的数学家和天文学家棣莫弗(Abraham de Moivre)于 1733 年首次提出,而高斯最先将其应用于了天文学研究,影响深远。

实验 ​

对了,咱前面提到图片体积可以压缩得更小。嘛,虽然是理所当然的事情,但是本着严谨起见,还是用数据说话。

同一张图片模糊前后,并使用 Squoosh 压缩。

模糊后589*600 blur:30px 模糊后 7.15KB

模糊前589*600 模糊前 29.71KB

此外,CSS 滤镜本身还是挺占性能的(Safari 好像优化的更好一些?),之前写主题尝试加了一堆模糊效果,虽然挺好看的,但是发现风扇嗡嗡响,最终还是忍痛割爱。

不过还有件神奇的事情。🤔️ Photoshop 处理的图片为什么就没有白边呢?

(后两张是网页中的效果,你可能会发现后者比 PhotoShop 模糊的厉害,单纯因为我在网页中缩小了图片再模糊以方便截图。本质效果是一样的。)

红色边框是原本图片所在的范围。

原图原图

Photoshop 高斯模糊 30px2419*1209 Photoshop 高斯模糊 30px

CSS filter 白色背景1119*559 CSS filter 高斯模糊 30px 白色背景

CSS filter 黑色背景1119*559 CSS filter 高斯模糊 30px 黑色背景

我们可以发现 CSS filter 模糊后的边界,准确来说并不是白边。而是透明的,我们应当叫它透明边或者 Alpha 边更为合适。

猜测 ​

高斯模糊本质是根据设定的模糊范围(30px 便是模糊半径为 30 像素),随后根据正态(高斯)分布计算出一个权重矩阵,因为要保证权重之和为 1,所以再进行归一化,再将其放到图片进行计算(将图片中的一个像素以及其模糊半径中的像素进行加权求和)得到模糊后的像素。(彩色图片则 RGB 三通道分别计算,带 Alpha 通道同理。)

所以我们可以大胆的猜测,在模糊的过程中,浏览器应该是把周围缺少的像素补足为完全透明的像素来计算高斯模糊。

而 Photoshop 则可能只将附近有的像素参与加权平均、或是将周边的像素翻转过去或者采用了什么更为神奇高端的算法猜测弥补了图片以进行高斯模糊。(因为看不到源码,所以只能猜猜了。)

实践 ​

我们不妨自己来实现一下(直接用 HTML5 的 Canvas 比较方便),看看是否能达成相似的效果。

计算过程中,免不了要写一些运算。譬如将高斯分布得到的权重矩阵,和模糊半径构成的像素矩阵进行点乘,最后求和,得到模糊后的像素。虽然遍历的时候就顺带求和更迅速,不过拆分成点乘和求和,更方便理解一些,也能使用一下现成的库。

Matrix math for the web

  • mathjs: JavaScript 数学扩展库
  • gl-matrix: (MDN 上钦点的)高性能 WebGL 应用程序的 Javascript 矩阵和矢量库(不过看起来原生支持优化了 2/3/4 维矩阵,主要用于 WebGL,但是我们模糊时的矩阵维数根据模糊半径可能很大,所以应该用不到。至于拆分矩阵,不如直接写完整的矩阵更易于理解和实现

高斯矩阵 ​

每次都去计算高斯分布显然是不合理的,所以我们应该先根据模糊半径算出一个权重矩阵,后续直接用来计算即可。

从维基百科偷一个高斯矩阵(σ = 0.84089642)7*7 示例:

0.000000670.000022920.000191170.000387710.000191170.000022920.00000067
0.000022920.000786330.006559650.013303730.006559650.000786330.00002292
0.000191170.006559650.054721570.110981640.054721570.006559650.00019117
0.000387710.013303730.110981640.225083520.110981640.013303730.00038771
0.000191170.006559650.054721570.110981640.054721570.006559650.00019117
0.000022920.000786330.006559650.013303730.006559650.000786330.00002292
0.000000670.000022920.000191170.000387710.000191170.000022920.00000067

一维正态分布:

f(x)=1σ2πe−(x−μ)22σ2{\displaystyle f(x)={\frac {1}{\sigma {\sqrt {2\pi }}}}e^{-{\frac {\left(x-\mu \right)^{2}}{2\sigma ^{2}}}}}

μ 是 x 的均值,σ2σ^2 是 x 的方差。记作 X∼N(μ,σ2)X \sim N(μ,σ^2)

因为我们使用中心点作为原点,那么均值 μ 便取 0。(取 1 的时候便是标准正态分布。) 但看了好多文章(发现很多最后都是抄自阮一峰的那篇……),都是直接假定 σ 为某一个数值,却没有说明原因。

而 OpenCV 源码中则当没有设置 σ 或 σ 不合法时,按照 σ=0.3∗((ksize−1)∗0.5−1)+0.8σ=0.3*((ksize-1)*0.5-1)+0.8 计算得出。而这个 ksize 正是中心点像素及周边像素的矩阵的尺寸,即 2*模糊半径+1。(OpenCV 文档中也说明其应当是一个奇数,印证了这一观点。)

getGaussianKernel()

N 维空间正态分布方程:

G(r)=12πσ2Ne−r2/(2σ2)G(r) = \frac{1}{\sqrt{2\pi \sigma^2}^N} e^{-r^2/(2 \sigma^2)}

二维空间中的高斯分布:

G(u,v)=12πσ2e−(u2+v2)/(2σ2)G(u,v) = \frac{1}{2\pi \sigma^2} e^{-(u^2 + v^2)/(2 \sigma^2)}

r2=u2+v2r^2 = u^2 + v^2


理论上来讲,图像中每点的分布都不为零,这也就是说每个像素的计算都需要包含整幅图像。在实际应用中,在计算高斯函数的离散近似时,在大概 3σ 距离之外的像素都可以看作不起作用,这些像素的计算也就可以忽略。通常,图像处理程序只需要计算 (6σ+1)×(6σ+1)(6 \sigma + 1) \times (6 \sigma + 1) 的矩阵就可以保证相关像素影响。对于边界上的点,通常采用复制周围的点到另一面再进行加权平均运算。 ——摘自维基百科

设「模糊半径」为 rσ=0.3∗(r−1)+0.8\sigma=0.3*(r - 1)+0.8 这么取应当便是为了避免计算一些几乎无用的数值。

对一幅图像进行多次连续高斯模糊的效果与一次更大的高斯模糊可以产生同样的效果,大的高斯模糊的半径是所用多个高斯模糊半径平方和的平方根。例如,使用半径分别为 6 和 8 的两次高斯模糊变换得到的效果等同于一次半径为 10 的高斯模糊效果,6×6+8×8=10\sqrt{6\times6 + 8\times8} = 10 。根据这个关系,使用多个连续较小的高斯模糊处理不会比单个高斯较大处理时间要少。

此外因为缩小矩阵多次处理并不会减少处理时间,我们就直接上手吧!

TypeScript 代码:

typescript

// 用了里面的矩阵(虽然自己遍历也行)
import * as math from 'mathjs'
/**
 * 计算高斯权重数值
 * @param x X坐标
 * @param y Y坐标
 * @param sigma
 */
function calculateGaussianWeight(x: number, y: number, sigma: number) {
  const PI = Math.PI
  // 方差
  const variance = sigma * sigma
  const weight
    = (1 / (2 * PI * variance)) * Math.exp(-(x * x + y * y) / (2 * variance))
  return weight
}

/**
 * 生成高斯矩阵
 * @param radius 模糊半径
 * @param sigma σ 0.3*(radius - 1)+0.8
 */
export function generateGaussianMatrix(radius: number, sigma?: number) {
  if (!sigma) {
    sigma = 0.3 * (radius - 1) + 0.8
  }

  // 矩阵尺寸
  const mSize = radius * 2 + 1
  let gMatrix = math.matrix(math.ones(mSize, mSize))

  gMatrix.forEach((val, index) => {
    const x = index[0] - radius
    const y = index[1] - radius
    gMatrix.set(index as any, calculateGaussianWeight(x, y, sigma))
  })

  gMatrix = normalization(gMatrix)
  return gMatrix
}

归一化 ​

typescript

/**
 * 归一化
 * @param matrix
 */
function normalization(matrix: math.Matrix) {
  const sum = math.sum(matrix)
  return math.divide(matrix, sum) as math.Matrix
}

高斯加权求和 ​

Uint8ClampedArray: 类型化数组表示一个由值固定在 0-255 区间的 8 位无符号整型组成的数组;如果你指定一个在 [0,255] 区间外的值,它将被替换为 0 或 255;如果你指定一个非整数,那么它将被设置为最接近它的整数。

typescript

export function blur(
  imageData: ImageData,
  width: number,
  height: number,
  options: BlurOptions
) {
  const data = imageData.data
  const originData = new Uint8ClampedArray(imageData.data)

  /**
   * 获取像素
   * @param x
   * @param y
   */
  function getPixel(x: number, y: number) {
    if (x < 0 || x >= height || y < 0 || y >= width) {
      return [0, 0, 0, 0]
    }
    else {
      const p = (x * width + y) * 4
      return originData.subarray(p, p + 4)
    }
  }

  const gMatrix = generateGaussianMatrix(
    options.radius,
    options.sigma ? options.sigma : null
  )

  const radius = options.radius

  let i = 0
  for (let x = 0; x < height; x++) {
    for (let y = 0; y < width; y++) {
      let r = 0
      let g = 0
      let b = 0
      let a = 0
      gMatrix.forEach((val, index) => {
        const dx = index[0] - radius
        const dy = index[1] - radius

        const pixel = getPixel(x + dx, y + dy)
        r += val * pixel[0]
        g += val * pixel[1]
        b += val * pixel[2]
        a += val * pixel[3]
      })
      data.set([r, g, b, a], i)
      i += 4
    }
  }
  return imageData
}

遍历图片中的每个像素,并按照高斯权重矩阵进行计算。绘制出计算后的图像。

好,收工。

gaussian-blur-30px-137007ms

emmm,可是速度很慢,1119*559 模糊半径 30px 便花了足足 137007ms。(MacBook Pro 15-inch 2019)

优化? ​

本来没打算有这个环节,但是发现纯粹按照高斯模糊本身的思路来写,实在是慢到不可思议。

我们来寻找是否有更迅速的方案达成类似的效果。

首先这个高斯矩阵是根据坐标来进行计算的,所以左上、左下、右上、右下的数值翻转后其实是完全一样的。 所以这期间每个像素有很多重复计算可以缓存下来以优化。(但是因为太懒,我就不写来……)

看到 基于 Canvas 实现的高斯模糊 提到可以将高斯模糊使用一维的高斯函数分别对 x 和 y 轴进行运算以优化速度,但仍旧还是有点慢的。

此外还可以缩小图片、间隔指定数量的像素去计算等。

而在 CodePen 上发现了一个 canvas Blur 几乎可以立即实现类似的效果。

其代码也不复杂,我们可以继续稍微优化一下。(其实就是改成先把矩阵算出来。)

typescript

// 复用计算高斯权重的函数
import { calculateGaussianWeight } from './index'

export class Blur {
  radius: number
  canvas: HTMLCanvasElement
  ctx: CanvasRenderingContext2D
  constructor(options: BlurOptions) {
    this.radius = options.radius || 3
  }

  initCanvas(canvas: HTMLCanvasElement) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
  }

  gaussianBlur() {
    const radius = this.radius
    const canvas = this.canvas
    const ctx = this.ctx

    // 矩阵尺寸
    const mSize = radius * 2 + 1
    const gMatrix = math.matrix(math.ones(mSize, mSize))

    let sum = 0
    const sigma = 0.3 * (radius - 1) + 0.8
    const step = radius < 3 ? 1 : 2

    // 跳着计算出高斯权重(所以总和不一样)
    for (let y = -radius; y <= radius; y += step) {
      for (let x = -radius; x <= radius; x += step) {
        const weight = calculateGaussianWeight(x, y, sigma)
        gMatrix.set([x + radius, y + radius], weight)
        sum += weight
      }
    }

    // 绘制不同透明度的原图,并偏移不同位置,叠加到原图上。
    for (let y = -radius; y <= radius; y += step) {
      for (let x = -radius; x <= radius; x += step) {
        ctx.globalAlpha
          = (gMatrix.get([x + radius, y + radius]) / sum) * radius
        ctx.drawImage(canvas, x, y)
      }
    }

    ctx.globalAlpha = 1
  }
}
Gaussian Blur Demo

很神奇地也能实现类似效果,但模糊程度还是略有不同的。(可以看到 Canvas 的高斯模糊也是有白边的。)

Fast Gaussian Blur 100px100px 1043ms

不过好像有点玻璃上有水滴流下来的那种感觉。

结论 ​

当然本质上,我们使用浏览器 Canvas 中 filter 的模糊要更快。(而 CSS 的 filter 白边问题我们仍旧无法从底层解决。)

最后一种方法可以迅速获得还不错的模糊效果,并消除所谓的白边。(而原生的高斯模糊算法在没有优化的情况下则完全不能用于生产。)

Reference ​

后话 ​

虽然起初想着实现起来应该不会太难,但仍旧粗心大意踩了些坑,也花了不少的时间。希望也能帮助大家加深一些理解吧。 难得水了一篇技术类文章,因为堆积的其他事务比较多,超出了自己预想的时间,最后有点草草结尾,还请见谅。 如有谬误,欢迎指正。


Q.E.D.