iT邦幫忙

2021 iThome 鐵人賽

DAY 26
0
Modern Web

成為Canvas Ninja ! ~ 理解2D渲染的精髓系列 第 26

Day 26 - 影像處理篇 - 用Canvas實作在IE上也可運行的模糊濾鏡II - 成為Canvas Ninja ~ 理解2D渲染的精髓

  • 分享至 

  • xImage
  •  

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去做運算,這樣就相對會消耗比較多資源 :(

開始程式實作

實錄影片:
Yes

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


上一篇
Day 25 - 影像處理篇 - 用Canvas實作在IE上也可運行的模糊濾鏡I - 成為Canvas Ninja ~ 理解2D渲染的精髓
下一篇
Day 27 - 3D繪圖篇 - 2D圖片上面的3D物件是怎麼產生的? I - 成為Canvas Ninja ~ 理解2D渲染的精髓
系列文
成為Canvas Ninja ! ~ 理解2D渲染的精髓31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言