Kalan's Blog

目前主題 亮色

Seam Carving – 讓圖片不成比例地縮小 | 興趣使然的研究之旅

Seam Carving 是我在 Computational Thinking 時學到的演算法,可以針對圖片的內容做縮放。一般將圖片渲染到網頁上時,如果大小不符合容器高、寬,我們會採取一些做法:

  1. 將圖片等比例縮放
  2. 剪裁掉一部分的圖片內容

Seam Carving 這個演算法可以在改變圖片比例的情況下做到裁剪,其原理是每次縮放時都找出圖片當中最不重要的部分並刪除,然後將圖片縫合起來。今天我們就來談談這個演算法吧!

我們來看看這張圖片

8f2adf0d-b228-4f4c-9108-8f39dbb39a89

如果我們將他的比例改成這樣

picture2

可以發現圖片的內容整個都變形,非常不好看。

Seam Carving 這個演算法可以做到大致保持圖片內容,又可以不按照比例縮放。實際上是怎麼做到的呢?今天就來介紹一下這個演算法。

Seam Carving

Seam Carving 的核心原理是找出圖片中最不重要部分刪除後,把剩下的部分「縫合」起來。那麼重點來了,要怎麼找到圖片中「不重要」的部分?又是怎麼定義不重要的呢?

邊緣檢測

我們先來看看這張圖片

rect-1

再來看看這張圖片

rect-2

這張圖片最重要的地方是黑白的交界點,只要能夠維持這個邊界,就算稍微修改比例,也不會感受到太大的差異。用肉眼看是看得懂,那麼在電腦視覺當中要怎麼找到這些點呢?

Sobel 邊緣檢測

Sobel 包含兩個 3x3 的矩陣:

Kernelx=[101202101]Kernely=[121000121]G=Kernalx2+Kernely2Kernel_x = \begin{bmatrix} 1 & 0 & -1 \\ 2 & 0 & -2 \\ 1 & 0 & -1 \end{bmatrix} \\ Kernel_y = \begin{bmatrix} 1 & 2 & 1 \\ 0 & 0 & 0 \\ -1 & -2 & -1 \end{bmatrix}\\ G = \sqrt{{Kernal_{x}}^2+{{Kernel_{y}}^2}}

如果將圖片當作一個矩陣,將圖片的像素與矩陣相乘並求算梯度值,等於對影像做卷積(Convolution)。Sobel 透過計算離散差分,可以算出圖像亮度的梯度值。

從矩陣來看可能不好理解,但如果用程式碼來表示的話應該會清楚很多:

// 程式碼參考 https://github.com/miguelmota/sobel/blob/master/sobel.js
function Sobel(imageData) {
  var width = imageData.width;
  var height = imageData.height;

  var kernelX = [
    [-1, 0, 1],
    [-2, 0, 2],
    [-1, 0, 1],
  ];

  var kernelY = [
    [-1, -2, -1],
    [0, 0, 0],
    [1, 2, 1],
  ];

  var sobelData = [];
  var grayscaleData = [];

  function bindPixelAt(data) {
    return function (x, y, i) {
      i = i || 0;
      return data[(width * y + x) * 4 + i];
    };
  }

  var data = imageData.data;
  var pixelAt = bindPixelAt(data);
  var x, y;

  for (y = 0; y < height; y++) {
    for (x = 0; x < width; x++) {
      var r = pixelAt(x, y, 0);
      var g = pixelAt(x, y, 1);
      var b = pixelAt(x, y, 2);

      var avg = (r + g + b) / 3;
      grayscaleData.push(avg, avg, avg, 255);
    }
  }

  pixelAt = bindPixelAt(grayscaleData);

  for (y = 0; y < height; y++) {
    for (x = 0; x < width; x++) {
      var pixelX =
        kernelX[0][0] * pixelAt(x - 1, y - 1) +
        kernelX[0][1] * pixelAt(x, y - 1) +
        kernelX[0][2] * pixelAt(x + 1, y - 1) +
        kernelX[1][0] * pixelAt(x - 1, y) +
        kernelX[1][1] * pixelAt(x, y) +
        kernelX[1][2] * pixelAt(x + 1, y) +
        kernelX[2][0] * pixelAt(x - 1, y + 1) +
        kernelX[2][1] * pixelAt(x, y + 1) +
        kernelX[2][2] * pixelAt(x + 1, y + 1);

      var pixelY =
        kernelY[0][0] * pixelAt(x - 1, y - 1) +
        kernelY[0][1] * pixelAt(x, y - 1) +
        kernelY[0][2] * pixelAt(x + 1, y - 1) +
        kernelY[1][0] * pixelAt(x - 1, y) +
        kernelY[1][1] * pixelAt(x, y) +
        kernelY[1][2] * pixelAt(x + 1, y) +
        kernelY[2][0] * pixelAt(x - 1, y + 1) +
        kernelY[2][1] * pixelAt(x, y + 1) +
        kernelY[2][2] * pixelAt(x + 1, y + 1);

      var magnitude = Math.sqrt(pixelX * pixelX + pixelY * pixelY);

      sobelData.push(magnitude, magnitude, magnitude, 255);
    }
  }

  var clampedArray = sobelData;

  return clampedArray;
}

從程式碼來看,Sobel 邊緣檢測實際在做的事是先將圖片轉為灰階後,再算出每個像素與相鄰像素的梯度值。

接下來我們把經過 Sobel 的圖片放到 canvas,這是原圖:

DSC00286

經過 Sobel 邊緣檢測後:

sobel

經過處理後的圖,可以發現交界處的部分(亮度變化大)的地方,值會越接近白色(也就是越靠近 255)。而亮度變化不明顯的地方則是呈黑色(靠近 0)

計算圖片中的不重要成分

由於邊緣是圖片中比較重要的部分(越白的地方越重要),因此我們可以由上往下從相鄰像素當中找出最小值,然後把它從圖片移除就可以了。

然而,如果我們直接從上往下去找(Greedy),他可能不是全域最小值,例如:

[0.90.80.70.10.10.050.11.51.5]\begin{bmatrix} 0.9 & 0.8 & 0.7 \\ 0.1 & 0.1 & 0.05 \\ 0.1 & 1.5 & 1.5 \end{bmatrix}

如果從 0.7 開始找,可以得到 0.7+0.05+1.5=2.25。但如果從 0.8 開始找,則會得到 0.8+0.1+0.1=1.0。我們把經由 sobel 處理過的矩陣資料叫做 energy。我們可以先針對此 energy 作預處理。

先從最底部開始,由於每次都是從最小值裡選,所以可以確定當前的最小值就是全域最小值,這樣就可以用動態規劃來撰寫。首先先將原本的 energy 最底部複製過去,由下往上,開始計算相鄰元素的最小值,並加上自身的值:

[1.11.01.30.20.61.150.11.51.5]\begin {bmatrix} 1.1 & 1.0 & 1.3 \\ 0.2 & 0.6 & 1.15 \\ 0.1 & 1.5 & 1.5 \end{bmatrix}

這樣子就可以直接從 1.0 開始找,依序是 0.20.1,由於已經經過預處理,可以保證這條路線是總 energy 最少的。

接下來就是將對應座標記錄起來,以上述的例子來說可以得到:

[[1, 0], [0, 1], [0, 2]]

最後再將對應的像素刪除後即可,用 JavaScript 實作如下:

function buildEnergyMap(data, width, height) {
  const energy = new Float32Array(width * height);
  const getIndex = (x, y) => (y * width + x) * 4;
  const getXY = (x, y) => y * width + x;
  // bottom
  // build bottom array
  for (let x = width - 1; x >= 0; x--) {
    const y = height - 1;
    energy[y * width + x] = data[getIndex(x, y)];
  }

  for (let y = height - 2; y >= 0; y--) {
    for (let x = 0; x < width; x++) {
      const left = Math.max(0, x - 1);
      const right = Math.min(x + 1, width - 1);
      const minEnergy = Math.min(
        energy[getXY(left, y + 1)],
        energy[getXY(x, y + 1)],
        energy[getXY(right, y + 1)]
      );

      energy[getXY(x, y)] += minEnergy;
      energy[getXY(x, y)] += data[getIndex(x, y)] / 255;
    }
  }

  return energy;
}

function seamCarving(canvas) {
  const ctx = canvas.getContext("2d");
  const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const { width, height, data } = imgData;

  // Find the seam with minimum energy using dynamic programming
  const energyMap = buildEnergyMap(data, canvas.width, canvas.height);
  const seam = findSeam(energyMap, canvas.width, canvas.height);

  for (let y = 0; y < height; y++) {
    const seamIndex = seam[y][0];

    for (let x = seamIndex; x < width - 1; x++) {
      const offset = (y * width + x) * 4;
      const nextOffset = (y * width + (x + 1)) * 4;
      data[offset] = data[nextOffset];
      data[offset + 1] = data[nextOffset + 1];
      data[offset + 2] = data[nextOffset + 2];
      data[offset + 3] = data[nextOffset + 3];
      data[nextOffset] = 255;
      data[nextOffset + 1] = 255;
      data[nextOffset + 2] = 255;
      data[nextOffset + 3] = 255;
    }
  }
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.putImageData(imgData, 0, 0);
}

function findSeam(energyMap, width, height, startPoint) {
  const getIndex = (x, y) => y * width + x;
  let min = Number.MAX_VALUE;
  let idx;
  for (let x = 0; x < width; x++) {
    if (min > energyMap[x]) {
      min = energyMap[x];
      idx = x;
    }
  }
  const seam = [];
  seam.push([idx, 0]);
  let x = idx;
  if (startPoint) {
    x = startPoint;
  }
  for (let y = 0; y < height - 1; y++) {
    let min = Number.MAX_VALUE;
    const left = Math.max(0, x - 1);
    const right = Math.min(x + 1, width - 1);
    const leftValue = energyMap[getIndex(left, y + 1)];
    const centerValue = energyMap[getIndex(x, y + 1)];
    const rightValue = energyMap[getIndex(right, y + 1)];
    const pairs = [
      [left, leftValue],
      [x, centerValue],
      [right, rightValue],
    ];

    let minX;
    for (let i = 0; i < pairs.length; i++) {
      min = Math.min(pairs[i][1], min);
    }
    const target = pairs.find((pair) => pair[1] === min);
    seam.push([target[0], y + 1]);
    x = target[0];
  }

  return seam;
}

效果

Demo 的連結在此

雖然我們說可以讓圖片不成比例縮放,但根據圖片內容,還是會看起來怪怪的,尤其是當圖片的重要訊息被移除後會越來越奇怪。儘管如此我還是覺得它是個很有趣的演算法。

移除不重要的部分,這個中心思想其實在很多地方都看得到,例如聲音採樣,人耳聽不到的高頻可以移除掉節省空間;矩陣太複雜了,透過 SVD 奇異值轉換用重要的特徵向量來表示。雖然看似互不關聯,但觀念相通。

後記

直接對圖片的每個像素做操作,而且又是用這種迴圈的方式來寫,在圖片尺寸比較大的時候會有非常顯著的 Delay。這時候可以考量一些優化,像是將對圖片的操作交給 GPU 處理,這部分可以用 WebGPU 或是 WebGL 達成,或是將計算挪給 Worker。

在實際使用上,雖然效果不錯,但其實還是要看圖片的內容為何,如果圖片的細節都很重要,都有非常明顯的亮度變化,那麼就算用 Seam Carving 也還是會失真很多。

相關資源

本篇很多概念是從 Computational Thinking – Seam Carving 這部影片而來,解說是 3Brown1Blue 的作者 Grant Sanderson!裡頭有大量的視覺話解說,非常好理解。

如果覺得這篇文章對你有幫助的話,可以考慮到下面的連結請我喝一杯 ☕️ 可以讓我平凡的一天變得閃閃發光 ✨

Buy me a coffee