視覺化學習:實現Canvas圖片區域性放大鏡

beckyye發表於2024-03-28

前言

最近我在視覺化課程中學習瞭如何在Canvas中利用畫素處理來實現濾鏡效果,在這節課程的結尾留了一道區域性放大鏡的題目,提示我們用畫素處理的方式去實現這個效果,最終實現隨著滑鼠移動將圖片區域性放大,本著把學到的內容落地實踐的想法,我就去思考了一番,但很不幸,我思考了好幾天也沒思考出結果,因為剛開始我想的一直是在一個Canvas上來操作,但是一來我對Canvas API還並不是很熟悉,二來我對畫素處理還不夠熟練,然後第三是如果原圖的部分畫素被處理了,那下一次放大就會有問題,因此我最終放棄了這個思路,選擇了再增加一個Canvas來完成最終的效果,以下就是利用這種方式實現圖片區域性放大的效果。

畫素處理

在實現這個效果之前,我們先來了解一下如何處理畫素,有些小夥伴可能不太清楚,所以這裡簡單說一下,在螢幕上我們知道所有顯示的內容都是由畫素點組成的,那麼在處理畫素之前,我們需要先獲取到畫素資訊,那麼Canvas就是提供了一個API叫做getImageData讓我們可以獲取到畫布上的畫素資訊,最終這個API返回的是一個ImageData型別的值,關於這個API的具體描述可以參考對應的MDN頁面

ImageData型別的資料包含三個屬性,包括data、width、height。width和height簡單來說,就是被提取畫素資訊的區域的寬高,最主要的畫素資訊是在這個data屬性中。data屬性指向一個陣列型別的值,準確來說是Uint8ClampedArray的例項,Uint8ClampedArray表示8 位無符號整型固定陣列,也就是說其中的元素是0到255之間的整數,我們知道一個畫素的顏色資訊可以使用rgba四個分量表示,那麼我們就得出在data陣列中每四個元素就能表示一個畫素點的資訊,因此data陣列的長度就是width * height * 4

瞭解完畫素處理,我們就可以開始進行具體的實現了。

具體實現

<canvas ref="canvasRef" width="0" height="0"></canvas>
<canvas ref="magnifier" width="0" height="0"></canvas><!-- 放大鏡 -->

1. 準備工作

在實現放大效果之前,我們需要先把圖片載入到Canvas上:

(async function() {
  const img = await loadImage('src/assets/girl1.jpg');
  canvasRef.value.width = img.width;
  canvasRef.value.height = img.height;
  context.drawImage(img, 0, 0);
}());

這裡loadImage方法是透過Image物件來非同步載入圖片,然後透過drawImage方法將圖片繪製到畫布上。

接著設定一個要放大的區域,也就是以滑鼠座標為中心,多少半徑以內的內容要被放大,這裡我設定一個變數originSize用於儲存原圖大小,並設定一個5倍的放大倍數。

let originSize = 40; // 原圖大小
let zoom = 5; // 放大倍數

(async function() {
  // ...
  magnifier.value.width = originSize * zoom;
  magnifier.value.height = originSize * zoom;
}());

用作於放大鏡的magnifier,我們使用originSize * zoom來設定它的寬高。

2. 滑鼠移動事件監聽

接下來就是主要的程式碼實現。

首先是新增滑鼠移動事件的監聽:

const addEvent = () => {
  canvasRef.value.addEventListener('mousemove', mouseDownHandler);
};

addEvent();

然後我們就來實現mouseDownHandler函式。

  • 首先我們獲取滑鼠座標在Canvas中的相對座標,並透過Math.floor取整

    const mouseDownHandler = e => {
      // 相對於畫布的座標
      const center = {
        x: Math.floor(e.pageX - left),
        y: Math.floor(e.pageY - top)
      };
    };
    
  • 然後利用getImageData方法獲取指定區域的畫素資訊,這裡我們用到了OffscreenCanvas,它提供了一個可以脫離螢幕渲染的 canvas 物件,可以提升渲染效能;這樣我們就得到了待放大區域的畫素資訊。

    const mouseDownHandler = e => {
      // 相對於畫布的座標
      // ...
      // 待放大區域的imageData
      const originImageData = getImageData(img, [center.x - originSize / 2, center.y - originSize / 2, originSize, originSize]);
    };
    
  • 現在我們需要一個ImageData型別的變數,用於儲存放大後的畫素資訊,因為最終要渲染到magnifier這個Canvas上,我們就使用magnifier的2d上下文物件呼叫createImageData方法來建立一個ImageData物件,關於這個方法的使用具體可檢視MDN文件

    const mouseDownHandler = e => {
      // 相對於畫布的座標
      // ...
      // 待放大區域的imageData
      // ...
      // 構建一個imageData
      const areaImageData = mContext.createImageData(magnifier.value.width, magnifier.value.height);
    };
    
  • 接下來就是具體的畫素遍歷和處理,按照areaImageData的寬高來進行遍歷,這裡迭代的增量使用+zoom是因為,當我們放大zoom倍數之後,原圖1個畫素的資訊在magnifier使用zoom*zoom個畫素來放大,也就是zoom*zoom個畫素點的色值和原圖中對應的那個畫素的色值是一樣的。在我們這段程式碼中設定zoom為5,也就是放大後使用5*5=25個畫素點表示之前的一個畫素點。

    const mouseDownHandler = e => {
      // 相對於畫布的座標
      // ...
      // 待放大區域的imageData
      // ...
      // 構建一個imageData
      // ...
      let count = 0;
      for (let j = 0; j < originSize * zoom; j += zoom) {
        for (let i = 0; i < originSize * zoom; i += zoom) {
    
          // ...
    
        }
      }
    };
    
  • 所以我們繼續使用兩個for迴圈k和m,把areaImageData的data陣列中的對應元素賦值為原圖對應畫素的色值,完成賦值後我們就可以透過putImageData方法將畫素資訊渲染到magnifier畫布上。

    const mouseDownHandler = e => {
      // 相對於畫布的座標
      // ...
      // 待放大區域的imageData
      // ...
      // 構建一個imageData
      // ...
      let count = 0;
      for (let j = 0; j < originSize * zoom; j += zoom) {
        for (let i = 0; i < originSize * zoom; i += zoom) {
    
          for (let k = j; k < j + zoom; k ++) {
            for (let m = i; m < i + zoom; m ++) {
              const index = (k * originSize * zoom + m) * 4;
              areaImageData.data[index] = originImageData.data[count];
              areaImageData.data[index + 1] = originImageData.data[count + 1];
              areaImageData.data[index + 2] = originImageData.data[count + 2];
              areaImageData.data[index + 3] = originImageData.data[count + 3];
    
            }
          }
          count += 4;
    
        }
      }
      mContext.putImageData(areaImageData, 0, 0);
    };
    

至此我們就實現了基本的區域性放大,但現在放大鏡不在原圖Canvas的上方,並且放大鏡是一個正方形,我們繼續簡單最佳化一下。

3. 簡單最佳化

  • 首先因為我對Canvas API還不太熟悉,所以我現在透過css把放大鏡改為圓形,並加上一個陰影box-shadow來最佳化視覺效果。

    #magnifier {
      position: absolute;
      box-shadow: 0 0 10px 4px rgba(12, 12, 12, .5);
      border-radius: 50%;
    }
    
  • 然後給兩個Canvas外層加一個div容器,把放大鏡設定絕對定位,把它放到滑鼠座標的位置,在滑鼠移動過程中更新放大鏡的位置。

    <div class="canvas-container" ref="containerRef" :style="{width: containerWidth + 'px'}">
      <canvas ref="canvasRef" width="0" height="0"></canvas>
      <canvas ref="magnifier" width="0" height="0" id="magnifier" :style="position"></canvas>
    </div>
    
    const position = reactive({
      left: 0,
      top: 0
    });
    const containerWidth = ref(0);
    
    containerWidth.value = img.width;
    // 在滑鼠移動過程中更新放大鏡的位置
    position.top = (center.y - originSize * zoom / 2) + 'px';
    position.left = (center.x - originSize * zoom / 2) + 'px';
    
    .canvas-container {
      position: relative;
      overflow: hidden;
    }
    
  • 這個時候放大鏡的位置就和我們預想的一致了,但是現在還有一個問題,就是放大鏡在原圖的上方,在移動的過程中會看到放大鏡的渲染有點卡頓,這是因為滑鼠移動事件是加在原圖Canvas上的,當滑鼠懸浮在放大鏡上時,這個移動事件的監聽就不連貫了,此時我們可以考慮把滑鼠移動監聽加改為加在外層容器上,這樣就能看到移動過程中放大鏡的渲染是比較流暢了。

    const addEvent = () => {
      containerRef.value.addEventListener('mousemove', mouseDownHandler);
    };
    

至此就完成了簡單的區域性放大效果,雖然還存在一些問題吧。

總結

以上的實現比較簡單粗暴,就是遍歷imageData然後賦值,不算什麼很高明的思路,就當作是拋磚引玉吧。

最終效果

完整程式碼

相關文章