用JAVASCRIPT调整内容感知图像的大小

文章内容来源于下面链接,仅供学习参考:

https://trekhleb.dev/blog/2021/content-aware-image-resizing-in-javascript/

目录内容:

1.  Provide you with an interactive content-aware resizer so that you could play around with resizing your own images(互式内容感知调整器)

2.   Explain the idea behind the Seam Carving algorithm(Seam Carving算法)

3.    Explain the dynamic programming approach to implement the algorithm (we'll be using TypeScript for it)()

注释:

1 Seam Carving 算法是2007年的《Seam Carving for Content-Aware Image Resizing》论文中提出的一种图像缩放算法,它的好处是可以尽可能保持图像中“重要区域”的比例,避免由于直接缩放造成的“失真”

内容感知的图像缩放算法一般用于图像的裁剪,有的时候,一张照片有点大,希望把它裁剪的小一些,但又想保留照片中的物体。

算法的c++实现地址:

https://github.com/insaneyilin/SeamCarving

python:

https://github.com/avidLearnerInProgress/pyCAIR

一、content-aware resizer

当涉及到更改图像比例(即在保持高度的同时减小宽度)以及不希望丢失图像的某些部分时,可能会应用内容感知图像大小调整。为了在改变图像比例的同时保留对象的比例,我们可以使用由Shai AvidanAriel Shamir引入的Seam Carving算法[1]

下面的示例显示了如何使用内容感知调整大小(左图)和直接缩放(右图)将原始图像宽度减少 50% 。在这种特殊情况下,左图看起来更自然,因为气球的比例被保留了下来。

内容感知图像大小调整

Seam Carving 算法的思想是找到对图像内容贡献最小的seam 接缝(连续的像素序列),然后对其进行雕刻(去除)。这个过程一遍又一遍地重复,直到我们得到所需的图像宽度或高度。在下面的示例中,您可能会看到热气球像素比天空像素对图像内容的贡献更大。因此,首先移除天空像素。

 

 

 

 

 

寻找能量最低的接缝是一项计算成本很高的任务(尤其是对于大图像)。为了使接缝搜索更快,可能会应用dynamic programming动态编程方法(我们将在下面详细介绍实现细节)。

Objects removal

每个像素的重要性(所谓的像素能量)是根据它在两个相邻像素之间的颜色 ( RGBA) 差异来计算的。现在,如果我们人为地将像素能量设置为某个非常低的水平(即通过在它们上面绘制一个mask),Seam Carving 算法将为我们执行对象移除

JS IMAGE CARVER demo

文章作者创建了JS IMAGE CARVER网络应用程序[2],在GitHub 上将其开源[3],可以使用它来调整自定义图像的大小。

更多的例子结果:

以下是该算法如何处理更复杂背景的更多示例。

使用更复杂的背景调整大小演示

使用更复杂的背景调整大小演示

Seam Carving 算法可能无法调整大多数像素为边缘的图像的大小。在这种情况下,它甚至开始扭曲图像的重要部分。在下面的示例中,内容感知图像大小调整看起来与直接缩放非常相似,因为对于该算法来说,所有像素看起来都很重要,并且很难将梵高的脸与背景区分开来。

算法未按预期工作时的示例

二、Seam Carving算法的工作原理

想象一下,我们有一张1000 x 500 px图片,我们想将其大小更改为500 x 500 px正方形(假设正方形比例更适合 Instagram需求)。在这种情况下,我们可能希望对调整大小过程设置几个要求

  • 保留图像的重要部分(即如果在调整大小之前有 5 棵树,我们希望在调整大小后也有 5 棵树)
  • 保留图像重要部分的比例(即圆形车轮不应被挤压到椭圆车轮)

为了避免改变图像的重要部分,我们可能会找到连续的像素序列(接缝),从上到下,对图像内容贡献最小(避免重要部分),然后将其删除。去除接缝会将图像缩小 1 个像素。然后我们将重复此步骤,直到图像获得所需的宽度。

问题是如何定义像素的重要性及其对内容的贡献(在原始论文中,作者使用术语“像素能量”energy of the pixel)。一种方法是将形成边缘的所有像素视为重要的像素。如果像素是边缘的一部分,则其颜色在相邻像素(左右像素)之间的差异将大于不属于边缘的像素。

像素色差

假设一个像素的颜色由4 个数字(R-红色、G-绿色、B-蓝色、A-alpha)表示,我们可以使用以下公式来计算色差(像素能量):

Pixel energy formula

在公式中:

  • mEnergy-中间像素的能量(重要性)(如果四舍五入)[0..626]
  • lR-左侧像素的红色通道值( )[0..255]
  • mR-中间像素的红色通道值( )[0..255]
  • rR-右侧像素的红色通道值( )[0..255]
  • lG-左侧像素的绿色通道值( )[0..255]

等等

在上面的公式中,我们暂时忽略了 alpha(透明度)通道,假设图像中没有透明像素。稍后我们将使用 Alpha 通道进行masking和对象移除。

像素能量计算示例

现在,既然我们知道如何找到一个像素的能量,我们就可以计算出所谓的能量图,它包含图像每个像素的能量。在每个调整大小的步骤中,应该重新计算能量图(至少部分地,下面会详细介绍)并且将具有与图像相同的大小。

例如,在第一个调整大小的步骤中,我们将有一个1000 x 500图像和一个1000 x 500能量图。在第二个调整大小步骤中,我们将从图像中移除接缝,并根据新缩小的图像重新计算能量图。因此,我们将获得999 x 500图像和999 x 500能量图。

像素的能量越高,它就越有可能是边缘的一部分,这对图像内容很重要,我们需要删除它的可能性也越小。

为了可视化能量图,我们可以为具有较高能量的像素分配较亮的颜色,为具有较低能量的像素分配较暗的颜色。这是一个关于能量图的随机部分可能是什么样子的人工示例。您可能会看到代表边缘的亮线,我们希望在调整大小时保留它。

能量图草图

这是上面看到的演示图像的能量图的真实示例(带有热气球)

能量图示例

我们可以使用能量图来找到能量最低的接缝(一个接一个),并通过这样做来决定最终应该删除哪些像素。

寻找接缝

找到能量最低的接缝并不是一项微不足道的任务,需要在做出决定之前探索许多可能的像素组合。我们将应用动态规划方法来加速它。

在下面的示例中,有第一个最低能量接缝的能量图。

带接缝的能量图示例

在上面的例子中,我们减少了图像的宽度。可以采用类似的方法来降低图像高度。不过,我们需要“轮换”该方法:

  • 开始使用顶部底部像素的邻居(而不是的)来计算像素能量
  • 对接缝进行搜索时,我们需要移动从(而不是从最高底部

在 TypeScript 中的实现

为了实现该算法,作者使用 TypeScript。如果你想要一个 JavaScript 版本,你可以忽略(删除)类型定义及其用法。

为简单起见,让我们仅针对图像宽度减少来实现接缝雕刻算法。

内容感知宽度调整(the entry function)

首先,让我们定义一些我们将在实现算法时使用的常见类型。

// Type that describes the image size (width and height).
type ImageSize = { w: number, h: number };

// The coordinate of the pixel.
type Coordinate = { x: number, y: number };

// The seam is a sequence of pixels (coordinates).
type Seam = Coordinate[];

// Energy map is a 2D array that has the same width and height
// as the image the map is being calculated for.
type EnergyMap = number[][];

// Type that describes the image pixel's RGBA color.
type Color = [
  r: number, // Red
  g: number, // Green
  b: number, // Blue
  a: number, // Alpha (transparency)
] | Uint8ClampedArray;

在高层次上,该算法包括以下步骤:

  1. 计算图像当前版本的能量图
  2. 根据能量图找到能量最低的接缝(这是我们将应用动态规划的地方)。
  3. 从图像中删除能量最低的接缝。
  4. 重复直到图像宽度减小到所需值。
type ResizeImageWidthArgs = {
  img: ImageData, // Image data we want to resize.
  toWidth: number, // Final image width we want the image to shrink to.
};

type ResizeImageWidthResult = {
  img: ImageData, // Resized image data.
  size: ImageSize, // Resized image size (w x h).
};

// Performs the content-aware image width resizing using the seam carving method.
export const resizeImageWidth = (
  { img, toWidth }: ResizeImageWidthArgs,
): ResizeImageWidthResult => {
  // For performance reasons we want to avoid changing the img data array size.
  // Instead we'll just keep the record of the resized image width and height separately.
  const size: ImageSize = { w: img.width, h: img.height };

  // Calculating the number of pixels to remove.
  const pxToRemove = img.width - toWidth;
  if (pxToRemove < 0) {
    throw new Error('Upsizing is not supported for now');
  }

  let energyMap: EnergyMap | null = null;
  let seam: Seam | null = null;

  // Removing the lowest energy seams one by one.
  for (let i = 0; i < pxToRemove; i += 1) {
    // 1. Calculate the energy map for the current version of the image.
    energyMap = calculateEnergyMap(img, size);

    // 2. Find the seam with the lowest energy based on the energy map.
    seam = findLowEnergySeam(energyMap, size);

    // 3. Delete the seam with the lowest energy seam from the image.
    deleteSeam(img, seam, size);

    // Reduce the image width, and continue iterations.
    size.w -= 1;
  }

  // Returning the resized image and its final size.
  // The img is actually a reference to the ImageData, so technically
  // the caller of the function already has this pointer. But let's
  // still return it for better code readability.
  return { img, size };
};

需要调整大小的图像以ImageData格式传递给函数。您可以在canvas上绘制图像,然后像这样从canvas中提取 ImageData:

const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, imgWidth, imgHeight);

让我们将每个步骤分解并实现calculateEnergyMap(),findLowEnergySeam()deleteSeam()功能。

计算像素的能量

这里我们应用上述色差公式。对于左右边界(当没有左右邻居时),我们忽略邻居,在能量计算时不考虑它们。

// Calculates the energy of a pixel.
const getPixelEnergy = (left: Color | null, middle: Color, right: Color | null): number => {
  // Middle pixel is the pixel we're calculating the energy for.
  const [mR, mG, mB] = middle;

  // Energy from the left pixel (if it exists).
  let lEnergy = 0;
  if (left) {
    const [lR, lG, lB] = left;
    lEnergy = (lR - mR) ** 2 + (lG - mG) ** 2 + (lB - mB) ** 2;
  }

  // Energy from the right pixel (if it exists).
  let rEnergy = 0;
  if (right) {
    const [rR, rG, rB] = right;
    rEnergy = (rR - mR) ** 2 + (rG - mG) ** 2 + (rB - mB) ** 2;
  }

  // Resulting pixel energy.
  return Math.sqrt(lEnergy + rEnergy);
};

计算能量图

我们正在处理的图像具有ImageData格式。这意味着所有像素(及其颜色)都存储在平面(1DUint8ClampedArray数组中。出于可读性的目的,让我们介绍几个辅助函数,它们将允许我们使用 Uint8ClampedArray 数组代替2D矩阵。

// Helper function that returns the color of the pixel.
const getPixel = (img: ImageData, { x, y }: Coordinate): Color => {
  // The ImageData data array is a flat 1D array.
  // Thus we need to convert x and y coordinates to the linear index.
  const i = y * img.width + x;
  const cellsPerColor = 4; // RGBA
  // For better efficiency, instead of creating a new sub-array we return
  // a pointer to the part of the ImageData array.
  return img.data.subarray(i * cellsPerColor, i * cellsPerColor + cellsPerColor);
};

// Helper function that sets the color of the pixel.
const setPixel = (img: ImageData, { x, y }: Coordinate, color: Color): void => {
  // The ImageData data array is a flat 1D array.
  // Thus we need to convert x and y coordinates to the linear index.
  const i = y * img.width + x;
  const cellsPerColor = 4; // RGBA
  img.data.set(color, i * cellsPerColor);
};

为了计算能量图,我们遍历每个图像像素并getPixelEnergy()针对它调用前面描述的函数。

// Helper function that creates a matrix (2D array) of specific
// size (w x h) and fills it with specified value.
const matrix = <T>(w: number, h: number, filler: T): T[][] => {
  return new Array(h)
    .fill(null)
    .map(() => {
      return new Array(w).fill(filler);
    });
};

// Calculates the energy of each pixel of the image.
const calculateEnergyMap = (img: ImageData, { w, h }: ImageSize): EnergyMap => {
  // Create an empty energy map where each pixel has infinitely high energy.
  // We will update the energy of each pixel.
  const energyMap: number[][] = matrix<number>(w, h, Infinity);
  for (let y = 0; y < h; y += 1) {
    for (let x = 0; x < w; x += 1) {
      // Left pixel might not exist if we're on the very left edge of the image.
      const left = (x - 1) >= 0 ? getPixel(img, { x: x - 1, y }) : null;
      // The color of the middle pixel that we're calculating the energy for.
      const middle = getPixel(img, { x, y });
      // Right pixel might not exist if we're on the very right edge of the image.
      const right = (x + 1) < w ? getPixel(img, { x: x + 1, y }) : null;
      energyMap[y][x] = getPixelEnergy(left, middle, right);
    }
  }
  return energyMap;
};

能量图将在每次调整大小迭代时重新计算。这意味着如果我们需要将图像缩小 500 像素,这不是最佳的,它将被重新计算,比方说,500 次。为了加快第 2 步、第 3 步和后续步骤的能量图计算,我们可能会仅为那些放置在将要移除的接缝周围的像素重新计算能量。为简单起见,此处省略了此优化,但可以在js-image-carver 存储库中找到示例源代码。

寻找能量最低的接缝(动态规划方法)

我们现在需要解决的问题是在能量图上从上到下找到像素能量总和最小的路径(接缝)。

The naive approach

The naive approach是一个接一个地检查所有可能的路径。

天真的方法

从上到下,对于每个像素,我们有 3 个选项(↙︎ 往左下,↓ 往下,↘︎ 往右下)。这给了我们O(w * 3^h)或时间复杂度O(3^h),其中wh是图像的宽度和高度。这种方法看起来很慢。

The greedy approach

我们也可以尝试选择下一个像素作为能量最低的像素,希望得到的接缝能量是最小的。

贪婪的方法

这种方法不会给出最差的解决方案,但不能保证我们会找到最好的可用解决方案。在上图中,可能会看到这种方法如何选择5而不是10,错过了最佳像素链。

这种方法的优点是速度快,时间复杂度为O(w + h),其中wh是图像的宽度和高度。在这种情况下,速度的代价是调整大小的低质量。我们需要在第一行(遍历w单元格)中找到最小值,然后我们只探索每行(遍历h行)的3 个相邻像素。

动态规划方法

可能已经注意到,在第一个方法中,我们在计算产生的接缝能量时一遍又一遍地总结相同的像素能量。

重复问题

在上面的示例中,我们可以看到,对于前两个接缝,我们正在重新使用较短接缝的能量(其能量为235)。而235 + 70我们要做四次操作,而不是只进行一次操作来计算第二个接缝的能量(5 + 0 + 80 + 150) + 70

因此,我们可以将当前接缝的特定像素处的能量保存在附加seamsEnergies表中,以使其可重新用于更快地计算下一个接缝(该seamsEnergies表将具有与能量图和图像本身相同的大小)。

我们还要记住,对于图像上的一个特定像素(即左下角),我们可能有多个先前接缝能量的值。

选择什么接缝

由于我们正在寻找具有最低产生能量的接缝,因此选择具有最低产生能量的前一个接缝也是有意义的。

接缝能量示例

一般来说,我们有三种可能的以前似乎可供选择:

三个选项可供选择

我们可以这样思考:

  • The cell [1][x]: contains the lowest possible energy of the seam that starts somewhere on the row [0][?] and ends up at cell [1][x]
  • The current cell [2][3]: contains the lowest possible energy of the seam that starts somewhere on the row [0][?] and ends up at cell [2][3]. To calculate it we need to sum up the energy of the current pixel [2][3] (from the energy map) with the min(seam_energy_1_2, seam_energy_1_3, seam_energy_1_4)

如果我们seamsEnergies完全填满表格,那么最低行中的最小数字将是尽可能低的接缝能量。

让我们尝试填充此表的几个单元格,看看它是如何工作的。

接缝能量图遍历

填写seamsEnergies表格后,我们可能会看到最低能量像素的能量为50。为方便起见,在seamsEnergies每个像素的生成过程中,我们不仅可以保存接缝的能量,还可以保存之前最低能量接缝的坐标。这将使我们能够轻松地重建从底部到顶部的接缝路径。

DP 方法的时间复杂度为O(w * h),其中wh是图像的宽度和高度。我们需要计算图像每个像素的能量。

以下是如何实现此逻辑的示例:

// The metadata for the pixels in the seam.
type SeamPixelMeta = {
  energy: number, // The energy of the pixel.
  coordinate: Coordinate, // The coordinate of the pixel.
  previous: Coordinate | null, // The previous pixel in a seam.
};

// Finds the seam (the sequence of pixels from top to bottom) that has the
// lowest resulting energy using the Dynamic Programming approach.
const findLowEnergySeam = (energyMap: EnergyMap, { w, h }: ImageSize): Seam => {
  // The 2D array of the size of w and h, where each pixel contains the
  // seam metadata (pixel energy, pixel coordinate and previous pixel from
  // the lowest energy seam at this point).
  const seamsEnergies: (SeamPixelMeta | null)[][] = matrix<SeamPixelMeta | null>(w, h, null);

  // Populate the first row of the map by just copying the energies
  // from the energy map.
  for (let x = 0; x < w; x += 1) {
    const y = 0;
    seamsEnergies[y][x] = {
      energy: energyMap[y][x],
      coordinate: { x, y },
      previous: null,
    };
  }

  // Populate the rest of the rows.
  for (let y = 1; y < h; y += 1) {
    for (let x = 0; x < w; x += 1) {
      // Find the top adjacent cell with minimum energy.
      // This cell would be the tail of a seam with lowest energy at this point.
      // It doesn't mean that this seam (path) has lowest energy globally.
      // Instead, it means that we found a path with the lowest energy that may lead
      // us to the current pixel with the coordinates x and y.
      let minPrevEnergy = Infinity;
      let minPrevX: number = x;
      for (let i = (x - 1); i <= (x + 1); i += 1) {
        if (i >= 0 && i < w && seamsEnergies[y - 1][i].energy < minPrevEnergy) {
          minPrevEnergy = seamsEnergies[y - 1][i].energy;
          minPrevX = i;
        }
      }

      // Update the current cell.
      seamsEnergies[y][x] = {
        energy: minPrevEnergy + energyMap[y][x],
        coordinate: { x, y },
        previous: { x: minPrevX, y: y - 1 },
      };
    }
  }

  // Find where the minimum energy seam ends.
  // We need to find the tail of the lowest energy seam to start
  // traversing it from its tail to its head (from the bottom to the top).
  let lastMinCoordinate: Coordinate | null = null;
  let minSeamEnergy = Infinity;
  for (let x = 0; x < w; x += 1) {
    const y = h - 1;
    if (seamsEnergies[y][x].energy < minSeamEnergy) {
      minSeamEnergy = seamsEnergies[y][x].energy;
      lastMinCoordinate = { x, y };
    }
  }

  // Find the lowest energy energy seam.
  // Once we know where the tail is we may traverse and assemble the lowest
  // energy seam based on the "previous" value of the seam pixel metadata.
  const seam: Seam = [];
  if (!lastMinCoordinate) {
    return seam;
  }

  const { x: lastMinX, y: lastMinY } = lastMinCoordinate;

  // Adding new pixel to the seam path one by one until we reach the top.
  let currentSeam = seamsEnergies[lastMinY][lastMinX];
  while (currentSeam) {
    seam.push(currentSeam.coordinate);
    const prevMinCoordinates = currentSeam.previous;
    if (!prevMinCoordinates) {
      currentSeam = null;
    } else {
      const { x: prevMinX, y: prevMinY } = prevMinCoordinates;
      currentSeam = seamsEnergies[prevMinY][prevMinX];
    }
  }

  return seam;
};

以最低能量去除接缝

一旦我们找到最低能量接缝,我们需要从图像中移除(雕刻)形成它的像素。移除是通过将接缝右侧的像素向左移动来1px进行的。出于性能原因,作者实际上并没有删除最后一列。相反,渲染组件将忽略超出调整后的图像宽度的图像部分。

删除接缝

// Deletes the seam from the image data.
// We delete the pixel in each row and then shift the rest of the row pixels to the left.
const deleteSeam = (img: ImageData, seam: Seam, { w }: ImageSize): void => {
  seam.forEach(({ x: seamX, y: seamY }: Coordinate) => {
    for (let x = seamX; x < (w - 1); x += 1) {
      const nextPixel = getPixel(img, { x: x + 1, y: seamY });
      setPixel(img, { x, y: seamY }, nextPixel);
    }
  });
};

对象移除

接缝雕刻算法首先尝试去除由低能量像素组成的接缝。我们可以利用这一事实,并通过手动为某些像素分配低能量(即通过在图像上绘制并掩盖其中的某些区域),我们可以使 Seam Carving 算法免费为我们进行对象移除

目前,在getPixelEnergy()函数中我们只使用RG,B颜色通道来计算像素的能量。但是还有我们还A没有使用的颜色的(alpha,transparency) 参数。我们可以使用透明通道来告诉算法透明像素是我们要删除的像素。可以检查考虑透明度的能量函数源代码

 

 

[1] 论文地址 https://perso.crans.org/frenoy/matlab2012/seamcarving.pdf

[2] JS在线 https://trekhleb.dev/js-image-carver/

[3] Github地址 https://github.com/trekhleb/js-image-carver

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值