在這篇文章中,我們要來實作上一篇提到的圖像模糊演算法~
在開始之前,因為有個小狀況是上一篇文中我們沒有提到的,我們要先稍微講解一下 --- 也就是邊緣像素
的處理
我們在上一篇有提到,圖像模糊的運算方式其實就是透過卷積核
上面的權重
去把每個像素做加權運算
。
BUT(就是這個BUT),那如果今天我們Loop處理到圖像邊邊的像素,就會出現這種狀況:
像這樣的狀況,也就是剛好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
卷積核
大小是3x3
),那麼其實可以把0位的這個像素,周圍的情形視為這樣: 0 0 1
0 0 1
10 10 11
也就是把周圍的像素往原本不存在像素
的地方做假性填補
,當然這部分我們不會實際產生這些虛構的像素,而是會設法去重複計算與邊緣像素
相鄰的像素其channel值。
這邊可能不太好懂, 所以我會簡單畫個圖說明。
我們在前一篇有提過,我們的方框模糊運算
其實是先取一次橫向的平均值,然後把這個橫向
的平均值
賦予到像素上,接著再取一次縱向
的平均值
,然後再賦予到像素
上。
這種橫向(或縱向)
取(加權)平均的動作,其實有一個正式的名稱,叫做動態模糊(Motion Blur)
這個就是只有做橫向取加權平均之後的結果
而每一次我們在做動態模糊
,而且又剛好碰到邊緣像素的時候。
我們可以用下列圖像來表示:
這邊的重點就是要判斷到底是要重複哪一顆像素的運算
,還有就是重複幾次
?
以上面橫向的情境
來看,我們可以透過((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