iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 20
0
Modern Web

JS30 錄系列 第 20

Day 20 - Webcam Fun - Part II - Pixel Manipulation

任務目標

接續昨天,這次要對擷取下來的影像做畫素操控,讓影像實現灰階、負片、左右翻轉、鏡像等各種光怪陸離的效果!像這樣

作法

前情提要,昨天我們已經將視訊鏡頭所拍攝的即時影像串流到 <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 運用這種特性來儲存圖像資訊。儲存的方式如下圖所示:


圖片引用自William Malone's Blog

圖片中的每個像素點都是由 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 第二十篇,有任何發現歡迎討論!

Reference

CanvasRenderingContext2D.getImageData()
完整程式碼


上一篇
Day 19 - Webcam Fun - Part I - Take a Photo from Webcam
下一篇
Day 21 - Speech Detection
系列文
JS30 錄30

尚未有邦友留言

立即登入留言