英文原文 trekhleb’s content-aware-image-resizing-in-javascript
大佬的GitHub项目地址 本文demo源码 | 本文demo | trekhleb
非常好的文章和代码,原作者大佬值得点star!
2024-10-16 修改一些阅读不通顺的地方。
一些术语
Seam Carving: 图像 接缝裁剪/接缝雕刻 算法(翻译参考知网)。
Seam: 接缝。连续的像素集合,从一端到另外一端的像素点连成的线(路径)。
Content-Aware内容感知:本文专指图像的内容感知。“内容”的抽象定义是图像中的视觉信息和结构,包括颜色、纹理、形状和图案等。比如自拍照里的人像,风景照里的壮丽山河等等图像想要表达的信息。
内容感知和接缝裁剪算法的关系:图像接缝裁剪是内容感知方法的一种算法实现。
参考文献
融入混合注意力的低缩放因子Seam Carving篡改检测算法
js实现基于内容感知的图像缩放方法
长话短说
世上已有很多论述Seam Carving(图像接缝裁剪)算法的好文,但我还是忍不住想要自己探索一下这个优雅、强大而又简单的算法,并写下我的个人体会。另一个吸引我的地方(作为javascript-algorithms的作者)是动态规划 (DP)思想可以丝滑地应用其中并解决问题。而且,如果你和我一样,沉迷于“钻研算法”,那么Seam Carving算法的DP解决方案可能会丰富你个人的DP知识库。
最后,在文中我将要做三件事:
- 提供一个可交互的基于内容感知的图像缩放工具以便你可以自由调整上传的图片
- 阐述Seam Carving算法原理
- 阐述实现该算法的动态规划方案(将使用TypeScript编码)
基于内容感知的图像缩放
当需要改变图像比例(比如减小宽度同时保持高度不变)且不希望丢失图像的某些部分时,可以使用内容感知图像缩放方法。如果直接对图像进行缩放则会扭曲其中的物体。那么为了在改变图像比例的同时保持图中物体的比例,我们可以使用Shai Avidan和Ariel Shamir提出的Seam Carving算法(图像接缝裁剪算法)。
下面的示例展示了分别用内容感知缩放(左图)和直接缩放(右图)将原始图像宽度缩小50%情形。在这个特定情况下,左图看起来更自然–因为气球的比例不变。
Seam Carving算法原理是找到对图像内容贡献最低的接缝(连续的像素集合),然后裁剪(移除)它。不断重复这个过程,直到图像宽度或高度满足我们需要。在下面的例子中,你可能会看到热气球像素对图像内容的贡献大于天空像素。因此,压缩图像的过程中天空像素首先被移除。
(译者注:csdn不支持传大于5mb的图片…无奈只能省略,推荐大家看原文或者我同传掘金的文章了)
寻找贡献最低的接缝是一项计算量很大的任务(特别是对于大图像而言)。为了加快搜索速度,可以采用动态规划方法(我们将在下面介绍实现细节)。
移除图片中的物体
每个像素的重要程度(也称为像素的能量,像素重要度)是根据其与相邻的两个像素之间的颜色(R
、G
、B
、A
)差异来计算的。现在,如果我们人为地将某些像素能量值调低(比如在它们上面绘制一个遮罩),Seam Carving算法就会为我们展示移除图像物体的过程。
Demo
Demo (译者注:markdown暂不支持做一个web组件展示,因此这里贴上大佬写的应用链接)
更多示例
下面是Seam Carving算法处理一些背景更复杂的图像示例。
图一,缩小后的图像背景上的山脉仍绵延,没有违和感。
图二海浪也是一样。缩小后保留波浪形状的同时没有扭曲人物。
但我们需要知道的是,Seam Carving算法并不是完美的,它可能无法调整大多数像素为边缘像素(这对于算法而言很重要)的图像的大小,甚至会扭曲图像的重要部分。在图三,内容感知缩放的表现看起来和直接缩放非常相似,因为对于算法而言,这幅画中的所有像素都很重要,难以区分哪些区域是梵高的脸,哪些区域是背景。
(译者注:边缘检测Edge Detection是计算机视觉方向一个经典论题。可以简单理解为区分重要内容和次要内容。这里说的Seam Carving的缺点是面对颜色多且碎且起伏大的图像缩放表现不佳)
Seam Carving算法原理
假设我们有一张1000 x 500 px
的图片,我们想改变它的尺寸,使之为500 x 500 px
的正方形图片。以此为前提,我们可能需要在处理的过程中提几个需求:
- 保留图像的重要部分(如果调整前有5棵树,我们希望调整后还是5棵树)。
- 保留图像重要部分的宽高比例(例如圆形车轮别被挤压成椭圆形车轮)。
为了避免修改图像的重要部分,我们可以找从上到下对图像内容贡献最小的 连续像素集合(接缝) ,然后将其删除。移除一条接缝将使图像缩小1个像素单位。重复此步骤,直到图像缩小到我们想要的宽度(从1000px宽度到500px宽度)。
那么问题来了,该如何定义像素的重要程度以及它们对图像内容的贡献呢?(在原论文中,作者使用了术语“像素能量” 代表像素的重要程度)其中一个方法是将构成边缘的所有像素视为重要像素,如果某个像素是边缘的一部分,那么它的颜色与相邻像素(左像素和右像素)之间的差异将大于不属于边缘的像素。
假设一个像素的颜色用4个数值表示( R
- 红色,G
- 绿色,B
- 蓝色,A
- alpha/透明度/不透明度),我们可以用以下公式来计算一个像素和相邻像素之间的颜色差异(差异值就是这个像素本身的能量值):
公式中:
mEnergy
- 中间像素的能量值,或者叫重要程度、重要度。四舍五入取值区间为[0, 626]
lR
- 左边像素的红色通道值,取值区间是[0, 255]
mR
- 中间像素的红色通道值,取值区间是[0, 255]
rR
- 右边像素的红色通道值,取值区间是[0, 255]
lG
- 左边像素的绿色通道值,取值区间是[0, 255]
- 以此类推…
在上面的公式中,我们假设图像不含透明像素,暂时忽略alpha(透明度)值。稍后我们将利用透明度值给图像画遮罩和移除物体。
现在,我们知道了如何算一个像素的能量,那我们就可以计算所谓的能量图(或者叫重要度图),它将包含图像中每一个像素的能量。在每次调整图像宽高时,能量图都会被重新计算(至少是被部分计算,下文将详细介绍),并且能量图的大小应与图像宽高相同。
例如,在第一次调整图像时,我们有一张1000 x 500
图像和一张1000 x 500
能量图。在第二次调整图像时,我们将从图像中移除一条接缝,并根据新的缩小后图像重新计算能量图。这样,我们将有一张999 x 500
图像和一张999 x 500
能量图。
一个像素的能量越高,它就越可能是边缘像素,越在图像中重要,因此更不可能被删除。
为了可视化能量图,我们可以为能量较高的像素分配较亮的颜色,为能量较低的像素分配较暗的颜色。下面是一个示例,随机选的一部分能量图的样子。你可能会看到代表边缘的亮线,我们希望在图像缩小期间保留它。
这是你在上面看到的“带热气球的天空”图像的可视化能量图示例。
我们可以借助能量图来找能量最低的接缝(点连点成线),最终决定删除哪些像素。
要找到能量最低的接缝并非易事,需要做很多排列组合。我们将使用动态规划思想加快计算速度。
下图,你可能会看到算法找到的可视化下的第一个最低能量接缝的能量图。
在上面的举例中(宽度1000缩小至500),我们缩小了图像的宽度。我们也可以采取类似的方法来缩小图像高度,但我们需要“转一下弯”:
- 用上方和下方像素作为邻居(而不是左侧和右侧)来计算像素能量
- 搜索接缝时,我们需要从左到右搜寻(而不是从上到下)
TypeScript代码实现
源代码 js-image-carver.
我们将用TypeScript实现该算法。如果你想要JavaScript版本,你可以忽略(删除)类型定义以及其他ts用法。
简单起见,我们将以缩小图像宽度为例,实现Seam Carving算法。
基于内容感知调整图像宽度(入口函数)
首先,让我们定义一些在实现算法时会用到的常见类型。
(译者注:注释将酌情翻译)
// 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 能量图类型,2d数组,大小等同于将用来计算的图的大小
// as the image the map is being calculated for.
type EnergyMap = number[][];
// Type that describes the image pixel's RGBA color. 描述像素点的RGBA颜色类型,包含红、绿、蓝、alpha透明度 通道值
type Color = [
r: number, // Red
g: number, // Green
b: number, // Blue
a: number, // Alpha (transparency)
] | Uint8ClampedArray;
总的来看,Seam Carving算法应包括以下步骤:
- 计算当前图像的能量图。
- 根据能量图找到一条能量最低的接缝(将用到动态规划解法)。
- 删除图像中的能量最低的接缝。
- 重复此操作直到图像宽度缩小到需求值。
type ResizeImageWidthArgs = {
img: ImageData, // Image data we want to resize. 原始图像ImageData信息
toWidth: number, // Final image width we want the image to shrink to. 期望缩小到的最终图像宽度
};
type ResizeImageWidthResult = {
img: ImageData, // Resized image data. 调整后的图像ImageData信息
size: ImageSize, // Resized image size (w x h). 调整后的图像宽高信息
};
// Performs the content-aware image width resizing using the seam carving method. 使用Seam Carving算法实现内容感知下的图像宽度调整
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 图像本质上是对其ImageData的饮用,所以实际上
// the caller of the function already has this pointer. But let's 函数的调用者已经有这个“指针”了,
// still return it for better code readability. 但为了更佳的代码可读性仍然返回了它
return { img, size };
};
待调整的图像已被以ImageData格式传递给函数。你可以在canvas上绘制图像,然后从画布中提取 ImageData,如下所示:
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, imgWidth, imgHeight);
使用JavaScript上传和绘画图像的方法不多赘述,你可以在js-image-carver项目里找到用React写的完整源码。
让我们将拆解每个步骤,实现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 数据结构。这意味着所有像素(及其它们本身颜色)都存储在一个一维的 Uint8ClampedArray 数组中。为了便于阅读,以下将介绍几个辅助函数,我们可以通过这些函数,像操作二维矩阵一样操作 Uint8ClampedArray 数组。
// Helper function that returns the color of the pixel. 返回图像某个位置的像素的rgba颜色值-辅助函数
const getPixel = (img: ImageData, { x, y }: Coordinate): Color => {
// The ImageData data array is a flat 1D array. ImageData里的数组数据是一维的
// Thus we need to convert x and y coordinates to the linear index. 因此我们需要把xy坐标转为一维线性坐标
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. 为了更高的效率,我们返回一个“指针”指向部分原ImageData数组,而不是创建新数组
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. 创建一个特定w x h大小的矩阵(二维数组)且填充制定值-辅助函数
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次(这还不是最理想的情况)。为了加快第二、三次以及后续迭代的能量图计算速度,我们可以只重新计算那些将被移除的接缝周围的像素能量。为简单起见,此处省略这项优化,但你可以在js-image-carver找到相关代码。
寻找最低能量接缝 (动态规划方法)
我之前在Dynamic Programming vs Divide-and-Conquer文章里写了一些动态规划基础知识,文中有一个最小编辑距离问题的动态规划解法示例,你可以看看它来了解一些解法信息。
现在的问题是如何找到能量图从上到下像素能量和最小的路径(接缝)。
朴素解法
朴素解法就是逐个检查可能的路径。
对于从上到下的每个像素,下一层有三个选项(选择左下方像素,正下方像素,右下方像素)。这解法的时间复杂度会是O(w * 3^h)
或者简单点是O(3^h)
(w,h为图像宽高),速度太慢。
贪心解法
我们也可以尝试在下一层像素中直接选最低能量的像素,希望最终的接缝能量总和值最小。
这种解法不会出现最差解,也不能保证是最优解。在上图中,你可以看到贪心算法最初选择了5
而不是10
作起点,错过了最优解路径。
这种解法的优点是速度快,时间复杂度为O(w + h)
,其中w
和h
分别是图像的宽度和高度。在这种情况下,高速度的代价是计算结果可能不准。该解法需要遍历第一行(即遍历w
列)找到最小值,然后只搜索往下每行的3个相邻像素(遍历h
行)。
动态规划解法
你可能已经注意到,在朴素法中,我们在算接缝的能量值时重复计算了相同子路径的像素能量。
在上面的例子中,你可以看到对于前两条接缝,我们重复使用了较短接缝的能量(其能量为235
)。但我们没有只运算一次235 + 70
来计算第二条接缝的能量,而是又运算了四次–(5 + 0 + 80 + 150) + 70
。
重复使用前一条接缝的能量来计算当前接缝的能量的行为会一而再地发生在其他接缝,直至最顶部的第一行接缝。当我们遇到这种重复的子问题时,说明此问题可以用动态规划思想解决。
因此,我们可以将当前接缝在特定像素位置的能量保存在seamsEnergies
表中,到时可以重新用,更快地计算下一条接缝(seamsEnergies
表的大小与能量图和图像本身的大小相同)。
请记住,对于图像上的一个特定像素(例如下图的左下角的像素),我们可能有几个之前计算保存下来的接缝能量可选。
既然我们在找最低能量的接缝,那么选择之前已经算过的最低能量子接缝拼接也是合理的。
一般情况下,我们有三条子接缝可选:
思路:
- 单元格
[1][x]
: 包含了从[0][?]
开始到[1][x]
的所有接缝中的最低一条的能量 - 当前单元格
[2][3]
: 包含了从[0][?]
开始到[2][3]
的接缝中可能的最低能量。当前单元格值存放的值为能量图中的[2][3]
位置对应的像素能量,加上min(seam_energy_1_2, seam_energy_1_3, seam_energy_1_4)
如果我们将seamsEnergies
表格完全填满,那么最下面一行中的最小数值就是最低的接缝能量。
看下接下来怎么跑。
在算法跑完seamsEnergies
表后,我们可以看到所有路径中最低的能量值是50
。为了方便起见,在seamsEnergies
里我们除了可以记录接缝的能量,还可以记录某处前一个能量最低的接缝的坐标。这使得我们能够轻松地从下到上连线接缝路径。
动态规划解法的时间复杂度为O(w * h)
,其中w
和h
分别是图像的宽度和高度。我们需要计算图像每个像素的能量。
以下举例一个实现思路:
// 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. 用dp找最低能量接缝
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. 从能量图复制数据填到seamsEnergies(接缝能量)图第一行
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()
函数中我们只用了R
、G
、B
颜色通道值来计算像素的能量,忽略了颜色的 A
(alpha, 透明度)通道值。我们可以往算法里加入透明度影响,以图移除图像的透明像素。你可以查看把透明度纳入计算的能量计算函数源码。
下面是移除指定物体的动画演示
问题和未来计划
本文讲解所用的JS IMAGE CARVERweb应用还远未可作商业用途,做这个的主要目的是为可交互地实验Seam Carving算法。因此,我未来计划是继续完善本次实验。
Seam Carving论文不仅描述了算法如何缩小图像,还描述了如何放大图像。相对于缩小,放大就是在移除物体后将图像放大回其原始宽度。
另一个有趣的部分是让算法即时运算。
这些就是我未来的计划,总的来说,我希望这次压缩图像的实验能能让你兴致满满且有所收获。此外我还希望你理解了用动态规划思想来解决问题。
最后,祝你的实验顺利!
–翻译结束–
碎碎念一下,我很喜欢源代码干净工整,且全面且详细到有点啰嗦的注释风格(一点都不惜字如金,带点难度的地方猛猛解释真是好评到爆炸!!)。如果文章有翻译错误或是不妥之处,还请留言或私信指教,感谢您的阅读!