Day 24 - 影像处理篇 - 用Canvas实作动态绿幕抠像 - 成为Canvas Ninja ~ 理解2D渲染的精髓

上一篇我们提到我们接着要开始玩一些比较有趣的实作~

所以我们就来讲讲怎麽在web端实作绿幕抠像(Green Screen Keying)~

什麽是绿幕抠像?

img

大家应该都有看过很多电影特效幕後花絮的照片,照片里面的特效演员会在一个绿色帆布架构成的场景前面拍戏,这种绿色帆布场景就是动画业界常常在说的绿幕

绿幕抠像(Green Screen Keying)指的就是把绿幕影片中的人物单独撷取出来的技术。

有些人可能会好奇为什麽要用绿色,据说是因为绿色是成效最好的一种颜色,大部分的戏剧拍摄道具/ 人类皮肤...,etc. 含有绿色的部分占比,平均来讲比较少。

其实这个技术在很多的影像後制软件里面都有对应的功能(例如After Effects的 Color Key),让使用者可以从拍摄好的绿幕影片中取得後制合成所需要的素材。

用Canvas实作做动态绿幕抠像

大家不知道还对我们之前使用过的ctx.drawImage 这个api有没有印象~

这个api可以让使用者去把指定的img source绘制到canvas上面,而除了image source以外,

他绘制的对象也可以是另外一张canvas(把某张canvas的内容画到现在这张canvas上面), 甚至是可以把影片(video)某一瞬间的静态画面画出来。

延伸阅读 - MDN 上的ctx.drawImage: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage

这个案例其实蛮简单的,主要的流程大致上就是

  • 首先创建一个video tag用来承接video source
  • 用ctx.drawImage 去By frame 把video 元素的画面绘制到另外一个canvas
  • 从绘制好video画面的canvas上面提取imageData
  • 把imageData做抠像处理
  • 把做好抠像处理的imageData再画到另外一张canvas上面(一共需要两张canvas)

接下来就是实作的部分~

而在开始实作之前我们必须要先有一个绿幕的影像拿来当做素材,这边可以去pixabay.com,这上面有提供很多免费的绿幕素材。

老样子放个实作的影片:

github repo : https://github.com/mizok/ithelp2021/blob/master/src/js/green-screen-keying/index.js
github page: https://mizok.github.io/ithelp2021/green-screen-keying.html

const videoSource = require('../../video/t-rex.mp4');
import { Canvas2DFxBase } from '../base';


class GreenScreenKeying extends Canvas2DFxBase {
  constructor(cvs, gmin = 150, rmax = 100, bmax = 100) {
    super(cvs);
    this.gmin = gmin;
    this.rmax = rmax;
    this.bmax = bmax;
    this.init();
  }

  init() {
    this.initScreens(videoSource, 500);
  }


  initScreens(videoSrc, size) {
    // 这边我们创建一个video tag用来承接透过require() import 进来的 source
    this.video = document.createElement('video');

    // 这边我们透过promise 来确保後续的程序都会在video 载入完毕之後执行, 这部分这样写的原因主要是因为要把canvas的大小设置成和影片一样,但是video 的长宽尺寸必须要在载入完毕之後才能正确取得(否则可能会取得0)
    let resolve;
    const promise = new Promise((res) => { resolve = res });


    this.video.addEventListener('loadeddata', () => {
      // Video is loaded and can be played
      resolve();
    }, false);

    // body 被按下的时候发动 video的play方法,然後开始canvas的渲染
    document.body.addEventListener('click', () => {
      this.video.play();
      this.animate();
    }, false);

    promise.then(() => {
      // videoWidth/videoHeight分别是video 的原始高/原始宽
      const vw = this.video.videoWidth;
      const vh = this.video.videoHeight;
      // 这边就是开始把canvas和video的大小都设定为一样
      this.videoStyleWidth = size;
      this.videoStyleHeight = (vh / vw) * size;
      this.video.style.width = this.videoStyleWidth + 'px';
      this.video.style.height = this.videoStyleHeight + 'px';
      this.video.setAttribute('playsinline', true); // 这一行是for ios装置, 避免他被play的时候自动变成全萤幕

      // 创建一个架空的canvas, 把他的长宽设定成跟video现在一样
      this.virtualCanvas = document.createElement('canvas');
      this.virtualCanvas.width = this.videoStyleWidth;
      this.virtualCanvas.height = this.videoStyleHeight;
      // 取得架空canvas的2Dcontext,并把它设置为本class的一项property
      this.virtualCtx = this.virtualCanvas.getContext('2d');
      this.setCanvasSize(this.videoStyleWidth, this.videoStyleHeight);
      document.body.prepend(this.video);
    })

    this.video.src = videoSrc;
    this.video.load(); // 这一行主要是for移动装置, 因为移动装置的loadeddata必须要用.load来触发
  }

  animate() {
    // 若影片停止或被暂停, 则停止canvas动画的渲染
    if (this.video.paused || this.video.ended) return;
    const $this = this;
    // 把当前video 的样子绘制在架空的canvas上
    this.virtualCtx.drawImage(this.video, 0, 0, this.videoStyleWidth, this.videoStyleHeight);
    // 取得架空canvas的imageData
    const virtualImageData = this.virtualCtx.getImageData(0, 0, this.videoStyleWidth, this.videoStyleWidth);
    // 把取得的imageData做绿幕抠像处理
    const keyedImageData = this.getKeyedImageData(virtualImageData);
    // 回填imageData
    this.ctx.putImageData(keyedImageData, 0, 0);
    requestAnimationFrame(this.animate.bind($this))
  }

  getKeyedImageData(imageData) {
    const data = imageData.data;
    const keyedImageData = this.ctx.createImageData(imageData.width, imageData.height);
    for (let i = 0; i < data.length; i = i + 4) {
      // 这边的运算其实也很简单,原理就是若侦测到g channel的值超过150 ,且 r和b都低於100(也就是颜色很可能偏绿),那就把该组像素的alpha channel值设置为0, 让他变透明 
      const r = data[i];
      const g = data[i + 1];
      const b = data[i + 2];
      keyedImageData.data[i] = r;
      keyedImageData.data[i + 1] = g;
      keyedImageData.data[i + 2] = b;
      if (g > this.gmin && r < this.rmax && b < this.bmax) {
        keyedImageData.data[i + 3] = 0;
      }
      else {
        keyedImageData.data[i + 3] = data[i + 3];
      }
    }
    return keyedImageData;
  }


}


(() => {
  const cvs = document.querySelector('canvas');
  const instance = new GreenScreenKeying(cvs);
})();

小结

这次我们介绍了如何在web端实现绿幕抠像,不过实际上这样的做法还算是比较阳春的方法。
因为这种抠像的运算方式(也就是透过设置channel最小值和最大值来阻挡绿色被输出)
其实在某些情况下还是会有瑕疵(例如发丝这种过细的图像,很容易因为出现透光的状况而导致沾到背景的颜色)这种情况可能就会需要更进阶的图形演算法(有兴趣的人可以去查查convolution kernel)。

另外,这次介绍的绿幕抠像其实也可以用在webcam影像,像是有些yt实况主会把自己的webcam画面去背放在实况画面上,有兴趣的人也可以自己尝试看看(不过可能要先学会怎麽自己架绿幕XD)


<<:  Day24 - Time complexity (DS篇)

>>:  Re: 新手让网页 act 起来: Day24 - React Hooks 之 useMemo

DAY07 - [CSS+RWD] 导览列

今日文章目录 > - 导览列 > - 练习演示 > - 遇到的问题 > -...

[铁人赛 Day15] 如何分析 memoization 的成效呢?Profiler API

Why Profiler ? Profiler 可以用来测量 React app render 的次...

Day01 - 人工智慧遇上语音辨识

近年来由於电脑硬体技术的提升使得机器/深度学习(Machine/Deep Learning)技术蓬勃...

学习Python纪录Day4 - Python资料型别

Python的程序注解 单行注解 → 以#开始 多行注解 → 以'''和'''括起 资料型别 数值资...

[Day23]-用python处理影像档案2

在影像内绘制图案 绘制点、线条 绘制多边形 *在影像内填入文字 小实作-制作Qrcode ...