iT邦幫忙

2021 iThome 鐵人賽

DAY 24
0
Modern Web

成為Canvas Ninja ! ~ 理解2D渲染的精髓系列 第 24

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)


上一篇
Day 23 - 影像處理篇 - 影像與色彩 - 成為Canvas Ninja ~ 理解2D渲染的精髓
下一篇
Day 25 - 影像處理篇 - 用Canvas實作在IE上也可運行的模糊濾鏡I - 成為Canvas Ninja ~ 理解2D渲染的精髓
系列文
成為Canvas Ninja ! ~ 理解2D渲染的精髓31

1 則留言

0
jerrythepotato
iT邦新手 5 級 ‧ 2021-10-10 11:33:15

用過筆電上那顆webcam,鏡頭放久了很髒,導致畫質很差ww

是說想問,先畫上去、再取用、再放上去,前提應該是「只操作canvas」,也符合這次的主題,不過想了解這三個步驟的效能差異:
drawImage()
getImageData()
putImageData()

有沒有可能直接拿到video的提供的ImageData呢?

看更多先前的回應...收起先前的回應...
Mizok iT邦新手 5 級 ‧ 2021-10-10 14:55:22 檢舉

這我不太確定 :(
不過就我知道的大部分網路上提出的做法都是用『先畫到第一張canvas上, 然後再從第一張canvas上取得imageData』這種做法。
(MDN也是用一樣的寫法)
https://stackoverflow.com/questions/12130475/get-raw-pixel-data-from-html5-video
這篇上面有提到你想問的問題,BUT他們討論到最後也沒有提出一個完美解答。(最後一個回答者還直接打槍他:()

Mizok iT邦新手 5 級 ‧ 2021-10-10 16:41:37 檢舉

如果是要提振效能,我覺得可能是要採取別種手段。
比方說降低canvas刷新率,畢竟影片的幀率一般沒有網頁這麼快。 以我這邊提出範例來講, 這邊其實可以把RAF改成setTimeout,並且把fps設定成24幀左右,這樣應該可以有效減少主線程堵塞的問題。

Mizok iT邦新手 5 級 ‧ 2021-10-10 17:11:26 檢舉

另外如果不是像webcam 那種需要即時渲染的情形,可能可以透過SSR的方法去把影片在server上做預處理,直接轉換成摳過像的影片。

Mizok iT邦新手 5 級 ‧ 2021-10-10 17:12:14 檢舉

其實我還有一種想法是可能可以透過web worker 去做摳像運算,不過這個我就沒有實作過~

哦哦!感謝回答好完整,去查了一下web worker,直接看到一個漩渦名人影分身,是很有趣的文章。長知識了,是個很不得了的東西呢。

多線程太適合拿來繪圖了,雖然在後台不能操作window和DOM物件,不過找著找著就突然看到原來影像魔術師系列有講到Canvas提供的方法XD:
const offscreen = canvas.transferControlToOffscreen()

Mizok iT邦新手 5 級 ‧ 2021-10-10 23:43:11 檢舉

不過offscreen canvas其實不支援IE XD,所以web worker 實用的場景也是有限制性的~

摁摁,直覺會覺得要拆成兩種情況去處理:

if (window.Worker) {
    // 畫面優先
}
else{
    // 效能優先
}

不過有用web worker的跟沒有使用的程式碼結構上應該也會差異蠻大的,如果真的這樣做大概就變成同一個功能,寫兩倍的分量了ww

如果真的這樣實作,電腦端就是效能優先然後引導用戶去用新版瀏覽器,而且至少safari是可以支援XD,畢竟ios用戶還真的沒得選,如果還不能用那寫這功能也沒意義ww

我要留言

立即登入留言