接續昨天,這次要對擷取下來的影像做畫素操控,讓影像實現灰階、負片、左右翻轉、鏡像等各種光怪陸離的效果!像這樣。
前情提要,昨天我們已經將視訊鏡頭所拍攝的即時影像串流到 <video>
中,再從 <video>
複製一份到 <canvas>
上。今天將利用 <canvas>
處理像素的能力將畫面改造一番後再扔回去 <canvas>
上,實現畫面特效的效果。
這次要實作七種效果,分別為「泛紅」、「負片」、「灰階」、「左右翻轉」、「鏡像」、「色相分裂」以及「過濾顏色」。
首先要改造一下 updateVideo
函數。 updateVideo
是在每個畫面循環中將 <video>
內的影像同步到 <canvas>
上的主要作用函數。 在裡面加上有註解的那三行,程式碼如下:
function updateVideo(timestamp, width, height) {
ctx.drawImage(video, 0, 0, width, height);
// 將 canvas 上的圖像資訊存成畫素陣列
let pixels = ctx.getImageData(0, 0, width, height);
// 改造畫素陣列來施加特效
pixels = redEffect(pixels);
// 把改造過的畫素陣列貼回去
ctx.putImageData(pixels, 0, 0);
window.requestAnimationFrame(timestamp => {
updateVideo(timestamp, width, height);
})
}
getImageData
是 <canvas>
2D 渲染環境的工具,它會把 <canvas>
當下的圖片資訊儲存在 ImageData
物件中並回傳。
如果用 console.log(pixels)
來檢視 ImageData
,會發現它是一個叫做 Uint8ClampedArray
的陣列。這種陣列裡的每個元素都被限制在 0 ~ 255 數字間,因而得名。 Canvas 的 ImageData
運用這種特性來儲存圖像資訊。儲存的方式如下圖所示:
圖片中的每個像素點都是由 R(Red) 、 G(Green) 、 B(Blue) 、 A(Alpha) 四種屬性組成。前三者操控顏色,最後一項操控透明度。每一種的數值都介於 0 ~ 255 之間。因此利用 Uint8ClampedArray
來儲存圖片,每一個畫素需要四個陣列欄位。而陣列欄位的順序由像素位置的先左到右、後上到下遞增。
getImageData
有四個參數 dx 、 dy 、 width 、 height。前兩個參數決定距原點向右與向下位移多少距離當作擷取原點, width 決定擷取多寬, height 決定擷取多高。這裡要擷取整張 <canvas>
的像素。
擷取後會自訂一些函數改變像素陣列的值,然後再透過 putImageData
將改造過的像素陣列貼回 <canvas>
上。
知道像素陣列的原理及程式的架構後,接下來就可以看看要怎麼改造像素陣列。
知道每個像素是由 Red 、 Green 、 Blue 、 Alpha 依序所組成,想要讓畫面泛紅,只需把 Red 的數值調高, Green 和 Blue 的數值降低即可。 程式碼如下:
function redEffect(pixels) {
for(let i = 0; i < pixels.data.length; i+=4) {
pixels.data[i] += 100;// red
pixels.data[i + 1] -= 100; // green
pixels.data[i + 2] *= 0.1; // blue
}
return pixels;
}
由於每一個像素由四個元素組成,我們用 for
迴圈來達到每四個為一組的調整。透明度不需要動,紅色按自己的方式調高,即可達到效果。
負片效果就是把所有顏色改成其互補色。255 是各顏色的最大值,將各個顏色用 255 去減就可以將各顏色變成其互補色,混合起來後就變成當下像素格的互補色。透明度一樣不須做調整。程式碼如下:
function contrast(pixels) {
for(let i = 0; i < pixels.data.length; i+=4) {
pixels.data[i] = 255 - pixels.data[i];// red
pixels.data[i + 1] = 255 - pixels.data[i + 1]; // green
pixels.data[i + 2] = 255 - pixels.data[i + 2]; // blue
}
return pixels;
}
灰色是怎麼來的?就是當紅色、綠色、藍色的值相同時。為了讓每個像素格變成灰色系,這裡使用的方法是先將該像素格的紅、綠、藍的平均,然後把紅、綠、藍的值改成都改成平均後的新值。程式碼如下:
function grayscale(pixels) {
for(let i = 0; i < pixels.data.length; i+=4) {
let avg = (pixels.data[i] + pixels.data[i + 1] + pixels.data[i + 2]) / 3;
pixels.data[i] = avg;// red
pixels.data[i + 1] = avg; // green
pixels.data[i + 2] = avg; // blue
}
return pixels;
}
不知道大家有沒有注意到,擷取到的視訊影像和我們的動作是左右相反的,和照鏡子不一樣啊!為了用 <canvas>
照鏡子,我們得手動左右翻轉影像。
程式碼如下:
function horizontalFlip(pixels, width, height) {
let newPixels = [];
for(let i = 0; i < pixels.data.length; i+=4) {
let iMirror = (width - ((i / 4) % width)) * 4 + Math.floor(i / 4 / width) * 4 * width;
newPixels[i] = pixels.data[iMirror];// red
newPixels[i + 1] = pixels.data[iMirror + 1]; // green
newPixels[i + 2] = pixels.data[iMirror + 2]; // blue
newPixels[i + 3] = 255; // opacity
}
for(let j = 0; j < newPixels.length; j++) {
pixels.data[j] = newPixels[j];
}
return pixels;
}
概念是先創造一個新陣列,以畫面中間為界,將左邊的像素搬到新陣列的右邊去,將右邊的像素搬到新陣列的左邊去,然後再把新陣列的元素一個一個填回原陣列。算式就留給大家參透了!
無意間做出了左右鏡像效果,概念是先把左邊的像素複製給右邊,當程式 loop 到右邊時,由於右邊的像素已經長得像左邊一樣了,所以複製回左邊還是左邊的樣子。程式碼如下:
function mirror(pixels, width, height) {
for(let i = 0; i < pixels.data.length; i+=4) {
let iMirror = (width - ((i / 4) % width)) * 4 + Math.floor(i / 4 / width) * 4 * width;
pixels.data[i] = pixels.data[iMirror];// red
pixels.data[i + 1] = pixels.data[iMirror + 1]; // green
pixels.data[i + 2] = pixels.data[iMirror + 2]; // blue
}
return pixels;
}
操控所有的紅色、綠色、藍色,將它們分別位移到不同的像素位置,就能達到該效果。程式碼如下:
function rgbSplit(pixels) {
for(let i = 0; i < pixels.data.length; i+=4) {
pixels.data[i - (640 * 40 + 80) * 4 + 1] = pixels.data[i];// red
pixels.data[i + (640 * 60 - 60) * 4 + 1] = pixels.data[i + 1]; // green
pixels.data[i - (640 * 80 + 40) * 4 + 1] = pixels.data[i + 2]; // blue
}
return pixels;
}
提供某個顏色區間,當顏色落在該區間時,就讓透明度變成 0。這和特效電影用綠屏讓人出現在神秘空間的原理是相同的!程式碼如下:
function greenScreen(pixels) {
const levels = {};
document.querySelectorAll('.rgb input').forEach((input) => {
levels[input.name] = input.value;
});
for (i = 0; i < pixels.data.length; i+=4) {
red = pixels.data[i + 0];
green = pixels.data[i + 1];
blue = pixels.data[i + 2];
alpha = pixels.data[i + 3];
if (red >= levels.rmin
&& green >= levels.gmin
&& blue >= levels.bmin
&& red <= levels.rmax
&& green <= levels.gmax
&& blue <= levels.bmax) {
// take it out!
pixels.data[i + 3] = 0;
}
}
return pixels;
}
做這個會需要用到的 HTML 滑動軸們:
<div class="rgb">
<label for="rmin">Red Min:</label>
<input type="range" min=0 max=255 name="rmin">
<label for="rmax">Red Max:</label>
<input type="range" min=0 max=255 name="rmax">
<br>
<label for="gmin">Green Min:</label>
<input type="range" min=0 max=255 name="gmin">
<label for="gmax">Green Max:</label>
<input type="range" min=0 max=255 name="gmax">
<br>
<label for="bmin">Blue Min:</label>
<input type="range" min=0 max=255 name="bmin">
<label for="bmax">Blue Max:</label>
<input type="range" min=0 max=255 name="bmax">
</div>
在練習的程式碼中,為了讓大家能夠切換並體驗各種效果的樣貌,加了切換鈕,改造了 updateVideo
程式碼如下:
const controls = document.querySelector('.rgb');
// 所有效果
const effectType = ['normal', 'red', 'contrast', 'grayscale', 'flip', 'mirror', 'split', 'filter'];
// 初始化效果選項
let effectIndex = 0;
// 切換效果選項的函數
function switchEffect() {
effectIndex = (effectIndex + 1) % 8;
}
function updateVideo(timestamp, width, height) {
ctx.drawImage(video, 0, 0, width, height);
let pixels = ctx.getImageData(0, 0, width, height);
controls.style.display = 'none';
// 依據當下切換效果的選項提供效果
switch(effectType[effectIndex]) {
case 'red':
pixels = redEffect(pixels);
break;
case 'contrast':
pixels = contrast(pixels);
break;
case 'grayscale':
pixels = grayscale(pixels);
break;
case 'flip':
pixels = horizontalFlip(pixels, width, height);
break;
case 'mirror':
pixels = mirror(pixels, width, height);
break;
case 'split':
pixels = rgbSplit(pixels);
break;
case 'filter':
controls.style.display = 'block';
pixels = greenScreen(pixels);
break;
default:
pixels = ctx.getImageData(0, 0, width, height);
break;
}
ctx.putImageData(pixels, 0, 0);
window.requestAnimationFrame(timestamp => {
updateVideo(timestamp, width, height);
})
}
以上就是 JS30 第二十篇,有任何發現歡迎討論!
CanvasRenderingContext2D.getImageData()
完整程式碼