iT邦幫忙

2023 iThome 鐵人賽

DAY 15
0
Modern Web

網頁的另一個大腦:從基礎到進階掌握 Web Worker 技術系列 第 15

在 Web worker 中操作圖像的 ImageData

  • 分享至 

  • xImage
  •  

昨天介紹了 ImageData 的用法,今天來寫個範例,看看如何在 Web worker 中處理 ImageData,並增進效能吧。

範例 Demo

目的

  1. 利用 ImageData 操作圖片的個別像素,讓圖片的外圍區域變的透明,最後渲染在 canvas 上。
  2. 因為 ImageData 的資料型別 Uint8ClampedArray 是一種 TypeArray,所以我們可以使用其中的 buffer 屬性 取得 ArrayBuffer,接著將其 轉移(transfer)worker 裡對個別像素進行處理,這部分是希望能利用 Web worker 增進效能的地方。

說明

  1. 範例中有兩個變數可調整數值,分別是 boundingOpacity: 調整透明度boundingRatio: 調整圖片外框影響比例
  2. 範例中有兩個按鈕執行 ImageData 的處理,其中一個丟到 worker 處理,另一個單純在主線程運行,最後希望比較兩者之間的差異
  3. 在可能的耗時操作前後都會計算經過的時間並顯示在畫面上。
  4. 為了能看出使用 worker 是否能避免主線程 UI 渲染阻塞,畫面上方會有一個紅色方塊隨著時間左右移動,可以觀察方塊動畫是否會卡頓。

https://ithelp.ithome.com.tw/upload/images/20230929/201626873vz6QnxnvB.png

首先我們先看點擊 使用 worker不使用 worker 按鈕時執行的程式碼,主要是根據點擊的按鈕執行操作 ImageData 的函數,並且在執行前後使用 performance.now() 計算函數執行時間,之後分別執行 renderCanvas 將處理過後的 canvas 畫在畫面上,以及 renderTime 將執行程式的時間顯示在畫面上。

// 根據點擊的按鈕執行操作 ImageData 的函數
const mapClassNameToFunction = {
  worker: makeImageBoundaryTransparentWithWorker,
  'no-worker': makeImageBoundaryTransparent
};

Array.from(document.querySelectorAll('button')).forEach((button) => {
  button.addEventListener('click', async (e) => {
    const originImg = document.querySelector('.origin');
    // 獲取原本圖片的 url
    const imageUrl = originImg.src;

    // 透明度
    const boundingOpacity =
      document.querySelector('.bounding-opacity').value || 0.5;
    // 圖片外框影響比例
    const boundingRatio =
      document.querySelector('.bounding-ratio').value || 0.05;

    // 操作 ImageData 函數
    const fn = mapClassNameToFunction[e.target.className];

    const start = performance.now();
    const { canvas, time } = await fn(imageUrl, {
      boundingOpacity,
      boundingRatio
    });
    // 將處理過後的 canvas 畫在畫面上
    renderCanvas(canvas);
    const end = performance.now();
    // 計算總耗時
    time.total = Math.round(end - start);

    // 將執行程式的耗時顯示在畫面上
    renderTime(time);
  });
});

由於 使用 worker 或是 不使用 worker 操作 ImageData 的邏輯是一樣的,所以下面只說明使用 worker 時執行的邏輯

取出 ImageData

將原圖使用 ctx.drawImage 畫在 canvas 上後,使用 ctx.getImageData 拿出 ImageData,由於 getImageData 是個耗時的操作,所以也測量使用 getImageData 前後所花費的時間

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const time = {};

// 測量 drawImage, getImageData 的耗時
start = performance.now();

// 在 canvas 上畫出 img 圖片
ctx.drawImage(img, 0, 0, width, height);
// 取出 ImageData
const imageData = ctx.getImageData(0, 0, width, height);

end = performance.now();
// getImageData 執行時間
time.getImageData = Math.round(end - start);

將 ImageData 轉移到 worker

由於 ImageDataTypeArray 型別,可以使用其中的 buffer 屬性將其 轉移 (transfer)worker 中進一步處理,這裡的 option 包含了 boundingOpacity: 調整透明度boundingRatio: 調整圖片外框影響比例

worker.postMessage({ imageData, option }, [imageData.data.buffer]);

worker 中操作 ImageData 設置透明度

worker 線程中會執行 makeImageDataTransparent 遍歷 ImageData 中的像素點,改變圖片外圍像素點的透明度,然後再將算完的 newImageData 回傳給主線程,此時會以 newImageDataTime 儲存將圖片外圍像素變透明所耗費的時間

// worker 線程
self.onmessage = async (e) => {
  const { imageData, option } = e.data;

  const start = performance.now();
  // newImageData 是操作後,圖片外圍像素已經變透明的
  const newImageData = await makeImageDataTransparent(imageData, option);
  const end = performance.now();

  self.postMessage({ newImageData, newImageDataTime: end - start }, [
    newImageData.data.buffer
  ]);
};

主線程接收操作過的 ImageData

最後將處理過後的 newImageData 利用 ctx.putImageData 更新回 canvas 上,這裡也一樣測量了執行 ctx.putImageData 所耗費的時間

// 主線程
worker.onmessage = (e) => {
  const { newImageData, newImageDataTime } = e.data;
  time.imageDataTime = Math.round(newImageDataTime);
  
  start = performance.now();
  ctx.putImageData(newImageData, 0, 0);
  end = performance.now();
  time.putImageData = Math.round(end - start);
};

結果

以下使用 Chrome 瀏覽器呈現

不使用 worker
可以看到每次點擊按鈕後,紅色方塊的動畫都會有明顯的停頓,主要是 操作圖片像素 (ImageData) 花費的時間約 100ms,此時會佔用主線程的資源,造成畫面卡頓
Yes

使用 worker
可以看到第一次點擊按鈕時,會有些許卡頓,主要是 取得圖片像素 (getImageData) 時花費約 80ms 的時間導致,但第二次後的點擊按鈕,即使 操作圖片像素 (ImageData) 的耗時約 100ms,但因為這段的運算資源移到 worker 中處理了,所以不會感到紅色方塊有所卡頓
Yes

結論

原本單純在主線程中運算 ImageData 操作圖片中的畫素,大約會花費 100ms 的時間處理,所以導致動畫有卡頓的狀況,而將這部分的運算移到 worker 處理後,因為不再佔用主線程資源,可以發現按下按鈕後的動畫是順暢的。

額外討論 - 高解析度圖片

各位可以修改程式碼把 index.html 中改為渲染高解析度的圖片,這時會發現即使使用 worker,動畫卡頓的問題一樣會出現,原因是圖片解析度變高時,getImageData 執行的時間會大幅上升,而 getImageData 並沒有移到 worker 中處理,所以依舊會造成卡頓。
這部分我們希望嘗試使用 OffscreenCanvasOffscreenCanvas 可以將 DOM 與圖像操作的邏輯解耦,做到在 worker 中呼叫 getImageData 方法,那麼明後天我們將會嘗試使用 OffscreenCanvas 優化處理高解析度圖片的狀況

補充小知識

  1. 什麼是被污染的畫布 (tainted canvas) ?
    不知道大家有沒有注意到範例中 execution.js 中有一行程式碼:
const img = new Image();
img.crossOrigin = "Anonymous";

為什麼這裡需要設定圖片的 crossOrigin 屬性呢?大家可以試試看移除這行後,會直接丟出錯誤 SecurityError Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data.

原因是任何圖片的來源都可能是 跨域(CORS) 的,在自己的網站獲取其他網站中的圖片並打算操作 圖片的像素(ImageData) 時是會有安全性問題的,所以當沒有設定 crossOrigin 時,瀏覽器就會丟出安全性錯誤,而將 crossOrigin 設定成 "Anonymous" 後,會告知瀏覽器說,這個圖片我打算用 CORS 的方式獲取,那麼這時候就會觸發瀏覽器的 CORS 檢查機制,也就是圖片的 response header必須回傳相關跨域設定:

Access-Control-Allow-Origin "*"

讓瀏覽器知道這個圖片是允許被跨域存取的,如此才有辦法使用 getImageData 方法獲取圖片中的每個像素值
Allowing cross-origin use of images and canvas
How to fix getImageData() error The canvas has been tainted by cross-origin data?

  1. 使用 Chrome 瀏覽器執行 getImageData 速度的差異
    大家在執行以上範例的時候不知道有沒有發現一個神奇的事情,使用 Chrome 瀏覽器,第一次執行 getImageData 時,速度明顯比第二次之後執行 getImageData 慢很多,似乎是因為 Chrome 瀏覽器目前都用 GPU 處理所有跟 canvas 相關的操作,通常來說使用 GPU 對 canvas 的各種操作都會快於 CPU,但有一個例外是 GPU 運行 getImageData 的速度明顯會慢於 CPU,所以 Chrome 決定將第一個 getImageData 的呼叫在 GPU 上運行,而後續的 getImageData 呼叫都在 CPU 上運行,於是就導致了第一次呼叫時速度很慢,但之後就都變快的神奇現象
    Canvas painting time issue with getImageData() in game loop

未解問題

  1. Safari 載入圖片很慢
    使用 Safari 執行以上範例時,會發現 載入圖片 (img.onload) 的時間竟然高達 3000ms 左右,而且不論按下幾次按鈕,載入圖片的時間都一樣這麼久,目前我還沒找到問題的原因,如果有人知道的話再幫忙告訴我,謝謝~
    https://ithelp.ithome.com.tw/upload/images/20230929/20162687Ci3cOx1VxM.png

上一篇
ImageData
下一篇
Transferable objects - OffscreenCanvas
系列文
網頁的另一個大腦:從基礎到進階掌握 Web Worker 技術30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言