Kalan's Blog

Kalan 頭像照片,在淡水拍攝,淺藍背景

四零二曜日電子報上線啦!訂閱訂起來

Software Engineer / Taiwanese / Life in Fukuoka
This blog supports RSS feed (all content), you can click RSS icon or setup through third-party service. If there are special styles such as code syntax in the technical article, it is still recommended to browse to the original website for the best experience.

Current Theme light

我會把一些不成文的筆記或是最近的生活雜感放在短筆記,如果有興趣的話可以來看看唷!

Please notice that currenly most of posts are translated by AI automatically and might contain lots of confusion. I'll gradually translate the post ASAP

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!裡頭有大量的視覺話解說,非常好理解。

Prev

奇異值分解

Next

JPG 與離散餘弦轉換

If you found this article helpful, please consider buy me a drink ☕️ It'll make my ordinary day shine✨

Buy me a coffee