Day 19 - Unreal Webcam Fun [更新]

前言

JS 30 是由加拿大的全端工程师 Wes Bos 免费提供的 JavaScript 简单应用课程,课程主打 No FrameworksNo CompilersNo LibrariesNo Boilerplate 在30天的30部教学影片里,建立30个JavaScript的有趣小东西。

另外,Wes Bos 也很无私地在 Github 上公开了所有 JS 30 课程的程序码,有兴趣的话可以去 fork 或下载。


本日目标

取得使用者的镜头影像,藉此实作出网页版的相机以及影像滤镜效果。


设定本地服务器

这次的范例会需要取得镜头的存取权限,而在取得过程必须是透过https或是localhost这类的secure origin才行,以下我们透过npm installnpm start架起自己的 little server (localhost)。

如果发现下面的指令无效的话,表示没有安装Node.js,可以点这边下载 LTS 的版本,都用预设的安装就好。

  • 第一步 : 先移动到编辑档案的工作目录,接着输入npm install,它会帮你安装一些套件

  • 第二步 : 输入npm start,它会去执行package.json里的start,开始运行一个 little server。(红色框是目前的网页位置)


解析程序码

HTML 部分

div(.controls) : 里面放置用来拍照的按钮和调整绿幕效果的按钮。

canvas(.photo) : 用来放入镜头的影像,之後会搭配一些滤镜。

video(.player) : 固定在右上角的小影像视窗。

div(.strip) : 用来放入撷取下来的图片。

audio(.snap) : 放入按下拍照按钮时要拨放的音效。

<div class="photobooth">
    <div class="controls">
        <button onClick="takePhoto()">Take Photo</button>
        <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>
    </div>

    <canvas class="photo"></canvas>
    <video class="player"></video>
    <div class="strip"></div>
</div>

<audio class="snap" src="./snap.mp3" hidden></audio>

JS 部分

老样子,我们要先取得所有要用到的元素。

.player 是小镜头画面。
canvas 是可以套上滤镜的大镜头画面。
ctxcanvas的渲染环境。
strip 是放照片的容器。
snap 是拍照的音效。

const video = document.querySelector('.player');
const canvas = document.querySelector('.photo');
const ctx = canvas.getContext('2d');
const strip = document.querySelector('.strip');
const snap = document.querySelector('.snap');

先来处理取得镜头影像的部分 :

navigator.mediaDevices.getUserMedia(),用来取得使用者的媒体装置,因为我们只需要取得影像,所以指定{video:true,audio:false}不存取音讯,最後回传一个Promise

我们用then()继续进行处理,因为不能直接将取得的MediaStream指定为video的来源(它看不懂QQ),还需要透过window.URL.createObjectURL()MediaStream换成video可以理解的URL,然後video.play()开始播放影像。

到这边,我们还需要用catch()来处理例外发生的状况,当无法顺利取得媒体装置或是媒体装置不存在,就会在 console 上印出错误讯息。

function getVideo(){
    navigator.mediaDevices.getUserMedia({video:true,audio:false})
    .then(localMediaStream => {
        console.log(localMediaStream);
        video.src = window.URL.createObjectURL(localMediaStream);
        video.play();
    })
    .catch(err => {
        console.log(`OH NO!!!`,err);
    });
}

getVideo();

把影像放到画布(canvas)上 :

为了让画布的大小和取得的影像大小一致,使用video.videoWidthvideo.videoHeight取得影像的宽、长,然後修改画布的宽(canvas.width)、长(canvas.height)。

setInterval(),设定每隔一段时间就把影像更新到画布(这边是设定16毫秒)。

ctx.drawImage(),把影像画到画布(canvas)上。

video.addEventListener('canplay',paintToCanvas),如果影像现在是可以正常播放的话,就持续将影像输出到画布上。

function paintToCanvas(){
    const width = video.videoWidth;
    const height = video.videoHeight;
    canvas.width = width;
    canvas.height = height;

    setInterval(() =>{
        ctx.drawImage(video,0,0,width,height);
    },16);
}

video.addEventListener('canplay',paintToCanvas);

按下Take Photo时的快门音效、把撷取下来的图片放入strip内供下载 :

snap.currentTime = 0,确保每一次都是从头开始播放音效,snap.play()开始播放。

canvas.toDataURL('image/jpeg')canvas上的影像转换成image/jpeg格式的URL档案连结。

const link = document.createElement('a'),在文件上新增一个<a>标签。

link.href = data,将标签连结指定为取得的影像图档连结。

link.setAttribute('download','handsome'),设定这个连结是可被点击下载,同时下载的档案名称为handsome

link.innerHTML = <img src="${data}" alt="handsome man" />,在<a>内部放入我们取得的图片,现在只要点击图片就会把图片下载下来。

strip.insertBefore(link,strip.firstChild),将整个<a><img></a>(撷取的影像图)插入到.strip里面并且是第一个位置。

function takePhoto(){
    //Play the sound
    snap.currentTime = 0;
    snap.play();

    const data = canvas.toDataURL('image/jpeg');
    console.log(data);
    const link = document.createElement('a');
    link.href = data;
    link.setAttribute('download','handsome');
    //link.textContent = 'Download Image';
    link.innerHTML = `<img src="${data}" alt="handsome man" />`;
    strip.insertBefore(link,strip.firstChild);
}

影像的滤镜效果 :

不同的滤镜效果其实只是将CanvasRenderingContext2D.getImageData()取得的画布像素(pixels)数据以每四个为一组(R-G-B-A)的方式修改,然後再将修改完的像素用CanvasRenderingContext2D.putImageData()放回画布。

1. 红色滤镜

增强红色并减弱绿、蓝色

function redEffect(pixels){
    for(let i=0;i < pixels.data.lenght;i+=4){
        pixels.data[i + 0] = pixels.data[i + 0] + 100;// R
        pixels.data[i + 1] = pixels.data[i + 0] - 50;// G
        pixels.data[i + 2] = pixels.data[i + 0] * 0.5;// B
    }
    return pixels;
}

2. 色彩分离

实际上是让色板产生位移 (这部分不太好理解@@)

function rgbSplit(pixels){
    for(let i=0;i < pixels.data.lenght;i+=4){
        pixels.data[i - 150] = pixels.data[i + 0];// R
        pixels.data[i + 500] = pixels.data[i + 0];// G
        pixels.data[i - 550] = pixels.data[i + 0];// B
    }
    return pixels;
}

3. 绿幕

让一定数值范围内的 R、G、B 消失。

建立一个空物件levels,接着放入每一个 range 的名称和数值。

以每四个为一组的方式取得画布像素的 R、G、B 数值,接着把颜色进行比对,举红色为例,如果像素的 R 值处在 rmin 和 rmax 之间,就把该像素的透明度设定为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 = 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;
 }

套用滤镜 :

(以套用红色滤镜为例,其他滤镜也是同理)

function paintToCanvas(){
    const width = video.videoWidth;
    const height = video.videoHeight;
    canvas.width = width;
    canvas.height = height;

    return setInterval(() =>{
        ctx.drawImage(video,0,0,width,height);
      
        //take the pixels out
        let pixels = ctx.getImageData(0,0,width,height);

        //mass with them
        pixels = redEffect(pixels);

        //put them back
        ctx.putImageData(pixels,0,0);
    },16);
}

这次的练习是目前为止最复杂的,连我自己本身也花了非常多的时间查资料,但仍然没有办法把细节交代清楚。

所以大家可能要多花些精力在学习这次的课程内容上,大家加油~~~

补充资料:

Enabling the Microphone/Camera in Chrome for (Local) Unsecure Origins
Navigator
Navigator.mediaDevices
MediaDevices.getUserMedia()
URL.createObjectURL()
CanvasRenderingContext2D.drawImage()
HTMLCanvasElement.toDataURL()
Document.createElement()
HTMLMediaElement
Element.setAttribute()
Node.insertBefore()
debugger
CanvasRenderingContext2D.putImageData()
CanvasRenderingContext2D.getImageData()

范例网页请点此

ps. 这次的网页比较特殊,如果打开镜头仍然无法看到效果的话,可能就要自己 fork 程序码到本地端测试~


<<:  # Day 10 Cache and TLB Flushing Under Linux (二)

>>:  30-4 之软件架构设计原则 3 - LSP 里氏替换原则

【Day 04】LeetCode:Fizz Buzz ( 用 JavaScript 学演算法 )

我们透过 LeetCode #412 Fizz Buzz 来实际感受解决问题的过程 ( 题目连结 )...

二、教你怎麽看source code,找到核心程序码 ep.22:Deeplab的model 部署

文章说明 文章分段 文章说明 deeplab的简单介绍、於我的意义 ep.1 tensorflow的...

.NET Core第18天_InputTagHelper的使用

InputTagHelper: 是针对原生HTML 的封装 新增InputController.cs...

Day27:Backtracking -回溯法

回溯法是暴力破解法的一种,在列出各种可能的组合时,如果遇到不符合条件的就不再继续向下查找,而是回到...

用React刻自己的投资Dashboard Day26 - 台股技术面功能规划

这篇终於要来开始做台股技术面的功能了,对於善於技术分析的投资人来说,看K线是非常基本的事情,因为从技...