Day 26 - 影像处理篇 - 用Canvas实作在IE上也可运行的模糊滤镜II - 成为Canvas Ninja ~ 理解2D渲染的精髓

img

在这篇文章中,我们要来实作上一篇提到的图像模糊演算法~

在开始之前,因为有个小状况是上一篇文中我们没有提到的,我们要先稍微讲解一下 --- 也就是边缘像素的处理

什麽是边缘像素?

我们在上一篇有提到,图像模糊的运算方式其实就是透过卷积核上面的权重去把每个像素做加权运算

BUT(就是这个BUT),那如果今天我们Loop处理到图像边边的像素,就会出现这种状况:

img

像这样的状况,也就是刚好loop到卷积核有一部分没有完全涵盖到图像的时候,我们把它称为Edge Case(边缘像素)

大部分Edge Case的应对方式会因应当前像素的位置而有微差异。

下面这种是比较常见的作法。

  • 假设这是一个图像的像素群
 0  1  2  3  4  5  6  7  8  9
10 11 12 13 14 15 16 17 18 19
20 21 22 23 24 25 26 27 28 29
30 31 32 33 34 35 36 37 38 39
40 41 42 43 44 45 46 47 48 49
50 51 52 53 54 55 56 57 58 59
60 61 62 63 64 65 66 67 68 69
70 71 72 73 74 75 76 77 78 79
80 81 82 83 84 85 86 87 88 89
90 91 92 93 94 95 96 97 98 99

  • 如果现在我们要做最左上角的那个像素的加权运算, 也就是0位的像素(假设卷积核大小是3x3),那麽其实可以把0位的这个像素,周围的情形视为这样:
 0  0  1
 0  0  1
10 10 11

也就是把周围的像素往原本不存在像素的地方做假性填补,当然这部分我们不会实际产生这些虚构的像素,而是会设法去重复计算与边缘像素相邻的像素其channel值。

这边可能不太好懂, 所以我会简单画个图说明。

我们在前一篇有提过,我们的方框模糊运算其实是先取一次横向的平均值,然後把这个横向平均值赋予到像素上,接着再取一次纵向平均值,然後再赋予到像素上。

这种横向(或纵向)取(加权)平均的动作,其实有一个正式的名称,叫做动态模糊(Motion Blur)

img

这个就是只有做横向取加权平均之後的结果

而每一次我们在做动态模糊,而且又刚好碰到边缘像素的时候。

我们可以用下列图像来表示:

  • 横向的情境:

img

  • 纵向的情境:

img

这边的重点就是要判断到底是要重复哪一颗像素的运算,还有就是重复几次?

以上面横向的情境来看,我们可以透过((11-1)/2) - 2 - ((11-1)/2) = -2 得知是重复逆推2位的那颗像素的运算,而且必须要重复Math.abs(2 - ((11-1)/2)) = 3

这部分的运算逻辑差不多就是这个样子,这麽一来就能一定程度上解决边缘像素的问题。

但是实际上我不确定这部分有没有更加优秀的处理方法,毕竟这种方法其实会依赖到loop去做运算,这样就相对会消耗比较多资源 :(

开始程序实作

实录影片:

github Repo : https://github.com/mizok/ithelp2021/blob/master/src/js/blur/index.js

github Page: https://mizok.github.io/ithelp2021/blur.html

import { Canvas2DFxBase } from '../base';
import * as dat from 'dat.gui'; // 这边我引用了dat.gui来做使用者操作和上传图片的ui

// 这个STATUS是为了dat.gui而设的
const STATUS = {
  blurSize: 0,
  imgSrc: function () {
    const imgUploader = document.getElementById('img-upload');
    imgUploader.click();
  }
}


class FilterBlur extends Canvas2DFxBase {
  constructor(cvs) {
    super(cvs);
  }

  // blurSize 指的是 (卷积核的宽度-1) / 2
  // 这个是用来判断持有某index的像素,到底是不是边缘像素,会回传一个阵列,
  // 用来表示他是否是上下左右某一个边缘上面的像素
  isRimPixel(pixelIndex, blurSize) {
    const isTopPx = pixelIndex / this.cvs.width < blurSize  //位於上边缘的像素
    const isLeftPx = pixelIndex % this.cvs.width < blurSize  //位於左边缘的像素
    const isBotPx = ~~(pixelIndex / this.cvs.width) > (this.cvs.height - 1) - blurSize //位於下边缘的像素
    const isRightPx = pixelIndex % this.cvs.width > (this.cvs.width - 1) - blurSize//位於右边缘的像素
    // const bool = isTopPx || isRightPx || isBotPx || isLeftPx;
    return [isTopPx, isRightPx, isBotPx, isLeftPx];
  }
  
  
  //  主要的方法,需要透过把class实例化,然後再去使用
  // blurSize 指的是 (卷积核的宽度-1) / 2
  boxBlur(img, blurSize = 1) {
    // 卷积核的宽度
    const kernelSize = blurSize * 2 + 1;
    const imgWidth = img.width;
    const imgHeight = img.height;
    let imageData, data;

    this.setCanvasSize(imgWidth, imgHeight);
    this.ctx.drawImage(img, 0, 0, imgWidth, imgHeight);
    
    // 这个就是用来计算平均的函数,可以输入一个boolean 来决定到底要算横向还是直向
    const calcAverage = (channelIndex, data, horizontal = true) => {
      const pixelIndex = channelIndex / 4;

      //接着总和横向所有像素 r/g/b/a的和, 取平均
      let rTotal, gTotal, bTotal, aTotal, rAverage, gAverage, bAverage, aAverage;
      rTotal = gTotal = bTotal = aTotal = rAverage = gAverage = bAverage = aAverage = 0;

      if (horizontal) {
        let repeatCounter = 0; // 
        for (let i = pixelIndex - blurSize; i < pixelIndex + blurSize + 1; i++) {
          //检查 像素i 有没有跟 持有pixelIndex的像素 在同一横列,如果没有,那就代表持有pixelIndex的像素与左边界或右边界的距离低於blurSize
          if (~~(i / imgWidth) !== ~~(pixelIndex / imgWidth)) {
            repeatCounter += 1
          }
          else {
            rTotal += data[i * 4];
            gTotal += data[i * 4 + 1];
            bTotal += data[i * 4 + 2];
            aTotal += data[i * 4 + 3];
          }
        }
        // 如果是右边缘的像素
        if (this.isRimPixel(pixelIndex, blurSize)[1]) {
          rTotal += data[(pixelIndex - blurSize + repeatCounter) * 4] * repeatCounter;
          gTotal += data[(pixelIndex - blurSize + repeatCounter) * 4 + 1] * repeatCounter;
          bTotal += data[(pixelIndex - blurSize + repeatCounter) * 4 + 2] * repeatCounter;;
          aTotal += data[(pixelIndex - blurSize + repeatCounter) * 4 + 3] * repeatCounter;;
        }
        // 如果是左边缘的像素
        else if (this.isRimPixel(pixelIndex, blurSize)[3]) {
          rTotal += data[(pixelIndex + blurSize - repeatCounter) * 4] * repeatCounter;
          gTotal += data[(pixelIndex + blurSize - repeatCounter) * 4 + 1] * repeatCounter;
          bTotal += data[(pixelIndex + blurSize - repeatCounter) * 4 + 2] * repeatCounter;;
          aTotal += data[(pixelIndex + blurSize - repeatCounter) * 4 + 3] * repeatCounter;;
        }
      }
      else {
        let repeatCounter = 0;
        for (let i = pixelIndex - imgWidth * blurSize; i < pixelIndex + imgWidth * (blurSize + 1); i = i + imgWidth) {
          //检查 i 若低於0, 或是大於最大位列像素的index,那就代表持有pixelIndex的像素与上边界或下边界的距离低於blurSize
          if (i < 0 || i > imgWidth * imgHeight - 1) {
            repeatCounter += 1
          }
          else {
            rTotal += data[i * 4];
            gTotal += data[i * 4 + 1];
            bTotal += data[i * 4 + 2];
            aTotal += data[i * 4 + 3];
          }
        }
         // 如果是上边缘的像素
        if (this.isRimPixel(pixelIndex, blurSize)[0]) {
          rTotal += data[(pixelIndex - imgWidth * (blurSize - repeatCounter)) * 4] * repeatCounter;
          gTotal += data[(pixelIndex - imgWidth * (blurSize - repeatCounter)) * 4 + 1] * repeatCounter;
          bTotal += data[(pixelIndex - imgWidth * (blurSize - repeatCounter)) * 4 + 2] * repeatCounter;
          aTotal += data[(pixelIndex - imgWidth * (blurSize - repeatCounter)) * 4 + 3] * repeatCounter;
        }
        // 如果是下边缘的像素
        else if (this.isRimPixel(pixelIndex, blurSize)[2]) {
          rTotal += data[(pixelIndex + imgWidth * (blurSize - repeatCounter)) * 4] * repeatCounter;
          gTotal += data[(pixelIndex + imgWidth * (blurSize - repeatCounter)) * 4 + 1] * repeatCounter;
          bTotal += data[(pixelIndex + imgWidth * (blurSize - repeatCounter)) * 4 + 2] * repeatCounter;
          aTotal += data[(pixelIndex + imgWidth * (blurSize - repeatCounter)) * 4 + 3] * repeatCounter;
        }
      }

      rAverage = rTotal / kernelSize;
      gAverage = gTotal / kernelSize;
      bAverage = bTotal / kernelSize;
      aAverage = aTotal / kernelSize;

      data[channelIndex] = rAverage;
      data[channelIndex + 1] = gAverage;
      data[channelIndex + 2] = bAverage;
      data[channelIndex + 3] = aAverage;
    }

    //---------------------------------------------------------

    // 取得当前的imageData
    imageData = this.ctx.getImageData(0, 0, imgWidth, imgHeight);
    data = imageData.data;

    // 先做一次水平的平均
    for (let i = 0; i < data.length; i = i + 4) {
      // i is channelIndex
      calcAverage(i, data)
    }

    //---------------------------------------------------------

    // 再做一次垂直的平均
    for (let i = 0; i < data.length; i = i + 4) {
      // i is channelIndex
      calcAverage(i, data, false)
    }

    this.ctx.putImageData(imageData, 0, 0);
  }

}

function initControllerUI() {
  const hiddenInput = document.createElement('input');
  hiddenInput.id = "img-upload";
  hiddenInput.type = 'file';
  hiddenInput.style.display = 'none';
  document.body.append(hiddenInput);
  const gui = new dat.GUI();
  const blurSizeController = gui.add(STATUS, 'blurSize', 0, 100, 1).name('模糊量');
  const fileUploader = gui.add(STATUS, 'imgSrc').name('上传图片');
  return {
    blurSizeController: blurSizeController,
    fileUploader: hiddenInput
  }
}


(() => {
  const cvs = document.querySelector('canvas');
  const blurKit = new FilterBlur(cvs);
  const gui = initControllerUI();
  const reader = new FileReader();
  const img = new Image();

  // 设定上传图片时的操作
  gui.fileUploader.addEventListener('change', (e) => {
    const file = e.currentTarget.files[0];
    if (!file) return;
    img.onload = () => {
      blurKit.boxBlur(img, STATUS.blurSize)
    }
    reader.onload = (ev) => {
      img.src = ev.target.result;
    }
    reader.readAsDataURL(file);
  })

  gui.blurSizeController.onChange(() => {
    blurKit.boxBlur(img, STATUS.blurSize)
  })

  //
})()

小结

我们在这边介绍了方框模糊(box blur) 的实作,而这篇其实还有改进的空间,主要是效能的方面应该还有机会再做提升,不过这可能就要更深入研究演算法的部分要怎麽样更加精简化(也许之後有时间可以再来回顾一下)

这篇其实是也是影像处理篇的最後一篇了,虽然说这一章篇幅有点短XD(主要是铁人赛也逐渐进入尾声了,而且也还要预留篇幅给剩下没讲的部分QQ)

希望大家会喜欢这次的介绍 :D


<<:  App 测试技能树

>>:  [DAY29] 接上金流系统,串接建立订单功能

数据来源身份真实-CBC-MAC

-密码学 问题是关於确保数据本身的完整性和数据来源的真实性,或者所谓的“真实性”,包括这两个概念。...

D28. 学习基础C、C++语言

D28. 题目练习 这次一样是练3n+1的题目,之前是用C语言,这次用C++来写 #include&...

饼乾的危险性:函式库 Library

诗忆最近正照着考古题写程序练习,写着写着她产生了疑问。「学姐,标准函式库这麽多函式可以用,什麽时候才...

<Day24> 什麽是上市、上柜、兴柜?什麽是ROD、IOC、FOK?

● 这章会简述及稍微解释一下上市、上柜、兴柜以及ROD、IOC、FOK的差别 首先,让我们再回顾一下...

D16 - 那个圆圆的东西 - 物件原型 & 原型链

前言 Object.prototype.like = '舌尖上的JS' 请在任意的 JavaScri...