使用不同插值类型缩放图像






4.91/5 (56投票s)
使用OpenGL实现不同插值[双线性与双三次]。
目录
引言
在OpenGL中,我们可以将纹理显示在一个小区域或大区域。以相同大小显示纹理需要将所有纹理单元(texel)复制到输出像素。如果我们想从一个小纹理创建一个大图像,OpenGL必须创建许多中间像素。这个创建中间像素的过程称为插值。
OpenGL提供两种插值方法来创建大的纹理图像。GL_NEAREST
和 GL_LINEAR
。我们可以将GL_NEAREST
或 GL_LINEAR
作为GL_TEXTURE_MAG_FILTER
参数传递给glTexParameterf()
函数。GL_NEAREST
是一种低质量插值,而GL_LINEAR
提供双线性插值。除了OpenGL的插值类型,这里还在像素着色器中实现了不同版本的双三次插值。双三次插值在从小纹理创建大图像时可以提供良好的图像质量。
ZoomInterpolation 应用程序的屏幕截图。使用不同的插值方法放大花朵的小区域。不同插值版本的质量有所不同。GL_NEAREST
类型的插值创建的是像素块状的图像,而CatMull-Rom或双三次样条则创建高质量的缩放图像。下面各节将详细解释不同插值的细节。
背景
插值是“一种利用共同的数学关系来估计两个已知值之间未知值的方法”。
ZoomInterpolation 以不同的插值类型创建缩放图像。OpenGL最多提供双线性插值。当将非常小的纹理区域放大到大区域时,双线性插值提供的质量较低。
双线性插值借助最近的4个像素来创建一个中间像素。双三次插值是一种高质量版本,它借助最近的16个像素来创建一个中间像素。
OpenGL GL_NEAREST 插值
GL_NEAREST
仅复制最近像素中的数据,当一个非常小的区域被放大到大屏幕区域时,它会产生像素块状的效果。在最近邻插值中,中间像素是通过最近的有效像素创建的。这种插值图像是块状的,因为相同的数据被复制到中间像素。
下面的图像是通过OpenGL GL_NEAREST
插值创建的,质量非常差。花朵的边缘看起来像是“楼梯”。我们可以通过更好的插值方法消除这种楼梯效应。
使用 GL_NEAREST
插值类型的插值。
我们可以使用以下代码在OpenGL固定功能管线中实现最近邻插值。
glTexParameteri( GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri( GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER, GL_NEAREST);
OpenGL GL_LINEAR 插值
OpenGL 提供了一种良好的插值方法 GL_LINEAR
。它插值最近的4个像素[X方向2个像素,Y方向2个像素]并与GL_NEAREST
方法相比,产生一个平滑的图像。GL_LINEAR
方法在X和Y方向都进行插值。因此,它被称为双线性插值。双线性插值的详细信息请参阅GLSL双线性部分。
以下图像是通过GL_LINEAR
插值创建的,与通过GL_NEAREST
插值创建的图像相比,其边缘是平滑的。这里看不到“楼梯”效应。但我们可以看到花朵边缘的黄色和绿色阴影,以及花朵某些边缘可见的小“楼梯”效应。
使用 GL_LINEAR
插值类型的插值。
可以使用以下代码在OpenGL固定功能管线中实现双线性插值。
glTexParameteri( GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER, GL_LINEAR );
glTexParameteri( GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER, GL_LINEAR );
GLSL 双线性
OpenGL只提供两种插值方法来创建图像(纹理)的缩放显示。当放大纹理的非常小的区域时,输出图像中可能会看到一些块状边缘。
创建更好的插值方法的第一步是创建双线性插值(这与GL_LINEAR
插值相同。但我们用像素着色器来处理双线性插值)。我们可以改进双线性插值的质量并获得更好的图像质量。
像素着色器是为每个渲染像素执行的程序。有关像素着色器的更多详细信息,请访问此处或此处。
双线性着色器实现
在双线性插值中,考虑最近的四个像素来创建一个中间像素。
从上图可以看出,中间像素 F(p’,q’) 是通过插值最近的四个纹理单元 F(p,q), F(p,q+1), F(p+1,q), F(p+1,q+1) 来创建的。
纹理单元(Texel)是用于指示纹理中一个元素的术语(类似于屏幕中的像素)[纹理单元,或纹理元素(也称为纹理像素),是纹理空间的根本单位]。
首先,通过线性插值[水平]对上行和下行的两个纹理单元进行插值。然后,对上行和下行的插值像素也进行线性插值[垂直]。代码解释将有助于理解上述描述。
使用插值因子a,在X方向上对F(p,q)和F(p,q+1)进行插值,如下所示
F(p’’) = (1.0 – a ) * F(p,q) + a * F(p+1, q) // Linear interpolation in X direction[Top].
F(p+1’’) = (1.0 – a ) * F(p,q+1) + a * F(p+1, q+1) // Linear interpolation in
// X direction[Bottom].
使用插值因子b,在Y方向上对F(p’’)和F(p+1’’)进行插值,如下所示
F(p’,q’) = (1.0 - b) * F(p’’) + b * F(p+1’’) // Linear interpolation in Y direction.
相应的GLSL着色器代码。
// Function to get interpolated texel data from a texture with GL_NEAREST property.
// Bi-Linear interpolation is implemented in this function with the
// help of nearest four data.
vec4 tex2DBiLinear( sampler2D textureSampler_i, vec2 texCoord_i )
{
vec4 p0q0 = texture2D(textureSampler_i, texCoord_i);
vec4 p1q0 = texture2D(textureSampler_i, texCoord_i + vec2(texelSizeX, 0));
vec4 p0q1 = texture2D(textureSampler_i, texCoord_i + vec2(0, texelSizeY));
vec4 p1q1 = texture2D(textureSampler_i, texCoord_i + vec2(texelSizeX , texelSizeY));
float a = fract( texCoord_i.x * fWidth ); // Get Interpolation factor for X direction.
// Fraction near to valid data.
vec4 pInterp_q0 = mix( p0q0, p1q0, a ); // Interpolates top row in X direction.
vec4 pInterp_q1 = mix( p0q1, p1q1, a ); // Interpolates bottom row in X direction.
float b = fract( texCoord_i.y * fHeight );// Get Interpolation factor for Y direction.
return mix( pInterp_q0, pInterp_q1, b ); // Interpolate in Y direction.
}
GLSL 双三次
在前一种方法中,使用最近的四个像素来创建一个中间像素。在此方法中,使用最近的16个像素来创建一个中间像素F(p’q’)。因此,输出图像质量会提高。下图显示,中间像素F(p'q')[靠近F(p,q)]是通过对F(p-1, q-1)到F(p+2,q+2)的最近4*4像素进行插值而创建的。本代码和必要方程将解释此插值的细节。
以下方程[来自Digital Image Processing: PIKS Inside, Third Edition]用于插值最近的16个像素。前两个sigma(S)在着色器代码中显示为2个for循环。
其中F(p+m,q+n)表示位置(p+m, q+n)处的纹理单元数据。Rc()表示双三次插值函数,如BSpline、Traingular、Bell cubic插值函数。在此示例中,我只使用了Triangular、Bell、B-Spline和CatMull-Rom插值函数。
以下是双三次插值的GLSL着色器代码
vec4 BiCubic( sampler2D textureSampler, vec2 TexCoord )
{
float texelSizeX = 1.0 / fWidth; //size of one texel
float texelSizeY = 1.0 / fHeight; //size of one texel
vec4 nSum = vec4( 0.0, 0.0, 0.0, 0.0 );
vec4 nDenom = vec4( 0.0, 0.0, 0.0, 0.0 );
float a = fract( TexCoord.x * fWidth ); // get the decimal part
float b = fract( TexCoord.y * fHeight ); // get the decimal part
for( int m = -1; m <=2; m++ )
{
for( int n =-1; n<= 2; n++)
{
vec4 vecData = texture2D(textureSampler,
TexCoord + vec2(texelSizeX * float( m ),
texelSizeY * float( n )));
float f = Triangular( float( m ) - a );
vec4 vecCooef1 = vec4( f,f,f,f );
float f1 = Triangular ( -( float( n ) - b ) );
vec4 vecCoeef2 = vec4( f1, f1, f1, f1 );
nSum = nSum + ( vecData * vecCoeef2 * vecCooef1 );
nDenom = nDenom + (( vecCoeef2 * vecCooef1 ));
}
}
return nSum / nDenom;
}
BiCubic()
函数接收纹理坐标(x,y)并返回最近16个纹理单元的插值结果。
最近的16个纹理单元通过2个for循环从[x-1,y-1]到[x+2, y+2]进行迭代。
for( int m = -1; m <=2; m++ )
{
for( int n =-1; n<= 2; n++)
对于每个最近的元素,使用相应的双三次插值函数计算插值因子。在上面的BiCubic()
函数中,使用Triangular()
来获得插值因子。通过改变Triangular()
及其逻辑,可以创建不同版本(BSpline、Traingular、Bell、CatMullRom)的双三次插值。绘制Triangular()
的值时,我们将得到如下三角形形式的图像
从三角形函数的绘制来看,X轴的最左边值是-2
,最右边值是+2
。Triangular
(x)在Y方向上绘制。它表示Triangular
函数对于较高的输入返回较低的值,对于较低的输入返回较高的值。
在BiCubic()
的每次迭代中,Triangular
()的返回值与当前数据相乘。结果是,通过插值最近的16个数据来创建一个中间像素。最近的像素权重最高,远处的像素权重较低。三角形双三次插值的输出图像比双线性插值更平滑。
双三次插值[三角形]实现
三角形函数是一个简单的双三次函数,定义如下方程
这里,当x在-1和0之间时,R(x)的返回值是x+1;当x在0和1之间时,返回值是x+1。以下伪代码可以更清晰地说明三角形函数。该函数为高输入提供低值,为低输入提供高值。
if( -1 < x && x <= 0 )
{
return x + 1
}
else if( 0 < x && x <= 1 )
{
return x - 1
}
上图是Triangular()
函数的输出。Triangular(x)
在Y方向上绘制。
以下代码用于GLSL着色器中实现三角形双三次插值。
float Triangular( float f )
{
f = f / 2.0;
if( f < 0.0 )
{
return ( f + 1.0 );
}
else
{
return ( 1.0 - f );
}
return 0.0;
}
使用GLSL双三次三角形插值类型的插值。
双三次插值[Bell]实现
将双三次三角形图像与双线性图像进行比较,图像质量有所提高。但三角形会产生模糊效果,所有边缘都变得平滑。边缘模糊的原因是Triangular()
返回的用于近纹理单元和远纹理单元的权重。如果近纹理单元的权重高,远纹理单元的权重非常低,那么双三次插值就可以实现平滑的边缘。另一种双三次插值函数,它产生一个钟形(最近值[中心]高),远值非常低[左右端]。
上述方程有三个条件来创建以下曲线。相应的代码在BellFunc()
中实现。如果我们绘制BellFunc()
从-2
到+2
的输入值在图上,我们会得到以下曲线。其中X方向包含提供给BellFunc
的值,Y方向是对应的返回值[BellFunc(x)
]。
上图表明,当x值较高时,BellFunc(x)
非常低,因此中间纹理单元的远数据获得非常低的权重。对于最近的纹理单元,BellFunc(x)
很高,并获得高权重。
以下代码用于GLSL着色器中实现钟形双三次插值。
float BellFunc( float x )
{
float f = ( x / 2.0 ) * 1.5; // Converting -2 to +2 to -1.5 to +1.5
if( f > -1.5 && f < -0.5 )
{
return( 0.5 * pow(f + 1.5, 2.0));
}
else if( f > -0.5 && f < 0.5 )
{
return 3.0 / 4.0 - ( f * f );
}
else if( ( f > 0.5 && f < 1.5 ) )
{
return( 0.5 * pow(f - 1.5, 2.0));
}
return 0.0;
}
使用双三次Bell插值类型的插值。
双三次插值[B-Spline]实现
将双三次钟形与B样条进行比较,插值图像更加平滑,边缘更清晰。使用以下方程来创建BSpline()
函数。
该方程的代码实现在BSpline()
函数中。
上图是BSpline()
函数的输出。BSpline(x)
在Y方向上绘制。x从-2.0开始到+2.0结束。这表明当x值较高时,BSpline(x)
非常低,因此中间纹理单元的远数据获得非常低的权重。与钟形波形相比,远距离范围(接近-2
和+2
)的值非常低。因此,最终输出图像也比钟形双三次插值图像更平滑。以下代码用于GLSL着色器中实现B样条插值。
float BSpline( float x )
{
float f = x;
if( f < 0.0 )
{
f = -f;
}
if( f >= 0.0 && f <= 1.0 )
{
return ( 2.0 / 3.0 ) + ( 0.5 ) * ( f* f * f ) - (f*f);
}
else if( f > 1.0 && f <= 2.0 )
{
return 1.0 / 6.0 * pow( ( 2.0 - f ), 3.0 );
}
return 1.0;
}
使用双三次BSpline插值类型的插值。
双三次插值[CatMull-Rom]实现
以上所有双三次方法都会产生模糊(或平滑)效果。应用以上插值方法后,图像边缘会变得平滑。此方法(CatMull-Rom方法)保留了图像的边缘。使用以下方程创建CatMullRom()
函数,该函数用于GLSL着色器中的CatMullRom
插值。
该方程可在Reconstruction Filters in Computer Graphics[方程8]中找到。如果我们将B设为0.0,C设为0.5,则该方程提供CatMul
-Rom插值。以下代码用于GLSL着色器中实现CatMul-Rom。
float CatMullRom( float x )
{
const float B = 0.0;
const float C = 0.5;
float f = x;
if( f < 0.0 )
{
f = -f;
}
if( f < 1.0 )
{
return ( ( 12 - 9 * B - 6 * C ) * ( f * f * f ) +
( -18 + 12 * B + 6 *C ) * ( f * f ) +
( 6 - 2 * B ) ) / 6.0;
}
else if( f >= 1.0 && f < 2.0 )
{
return ( ( -B - 6 * C ) * ( f * f * f )
+ ( 6 * B + 30 * C ) * ( f *f ) +
( - ( 12 * B ) - 48 * C ) * f +
8 * B + 24 * C)/ 6.0;
}
else
{
return 0.0;
}
}
CatMull
Rom的插值曲线与其他双三次插值曲线略有不同。这里的非常近的数据获得高权重进行乘法运算,而远数据获得负值。因此,远数据对中间像素的影响将被消除。
关于ZoomInterpolation 应用程序
ZoomInterpolation
是一个用于演示不同插值方法的应用程序。启动时,它会创建一个带有位图[Flower.bmp]的纹理,该位图位于该应用程序的资源中。
BMPLoader BMPLoadObj; // To load an RGB of a bmp file
BYTE* pbyData = 0;
BMPLoadObj.LoadBMP( IDB_BITMAP_FLOWER, m_nImageWidth, m_nImageHeight, pbyData );
m_glTexture.Create( m_nImageWidth, m_nImageHeight, pbyData );// Texture creating with bmp
该应用程序显示两个图像,左侧是缩放后的图像,右下角是原始图像。使用两个视口来显示原始图像和缩放后的图像。红色的矩形表示缩放后的图像。选择图像的小区域进行纹理映射并显示在屏幕上。
以下代码用于绘制原始图像,并用红色矩形指示缩放后的图像区域。
/*
This function draws miniature of actual image with a Red region
indicating the zoomed area.
*/
void CZoomInterpolationDlg::DrawActualImage()
{
// Set Rendering area of Actual image.
glViewport( 805, 10, 200, 150 );
// Image is attached.
m_glTexture.Enable();
// Entire image is mapped to screen.
m_glVertexBuffer.DrawVertexBuffer( GL_QUADS );
m_glTexture.Disable();
// Set Red color for Zoom area indication.
glColor3f( 1.0, 0.0, 0.0 );
float fXStart = m_fXOffset * 2;
float fYStart = m_fYOffset * 2;
float fWidth = m_fZoomWidth * 2;
float fHeight = m_fZoomHeight * 2;
// Draw a rectangle indicate zoom area.
glBegin( GL_LINE_LOOP );
glVertex2d( -1.0 + fXStart , -1.0 + fYStart );
glVertex2d( -1.0 + fXStart + fWidth, -1.0 + fYStart );
glVertex2d( -1.0 + fXStart + fWidth, -1.0 + fYStart + fHeight );
glVertex2d( -1.0 + fXStart , -1.0 + fYStart + fHeight );
glVertex2d( -1.0 + fXStart , -1.0 + fYStart );
glColor3f( 1.0, 1.0, 1.0 );
glEnd();
}
以下代码用于使用当前插值创建缩放图像。
/*
Creating zoomed image.
*/
void CZoomInterpolationDlg::DrawZoomedImage()
{
// Displays all views. Draw()_ method of GLImageView prepares the
// Zoomed image of a view. m_ImageView list holds single object in normal case.
// When All In View is selected in Interpolation type, m_ImageView will hold 7 View
// objects.
for( int nViewIndex = 0; nViewIndex < m_ImageView.size(); nViewIndex++ )
{
(m_ImageView[nViewIndex])->Draw();
}
}
在ZoomInterpolation
中,准备了一个渲染区域,然后设置了两个视口来显示两种类型的图像。第一个显示缩放后的图像,第二个显示原始图像的缩略图。CZoomInterpolationDlg::DrawActualImage()
和CZoomInterpolationDlg::DrawZoomedImage)
的第一步的glViewport()
调用用于准备不同的图像显示区域,缩放图像和原始图像的缩略图。
平移操作
当鼠标点击缩放图像并拖动时,纹理映射区域会随着鼠标移动而改变,从而产生平移效果。
缩放与取消缩放
当鼠标滚动时,通过增加或减少纹理映射区域来实现缩放/取消缩放。CZoomInterpolationDlg::HandleZoom()
处理缩放和取消缩放。还添加了两个按钮“Zoom+”和“Zoom-”来增加或减少缩放。
加载新图像
有一个“Load Image”按钮,用于从您的机器加载图像文件。图像区域以4:3的宽高比创建[800X600像素]。因此,为了获得更好的质量,输入图像应以4:3的宽高比提供。否则将显示拉伸/变形的图像。BMPLoader
类用于在Gdi+库的帮助下从位图中检索RGB数据。该类支持不同的图像扩展名,如.bmp, .jpg, .png, .tga等。
保存缩放图像
_rps 在本文中添加了评论,用于将缩放后的图像保存为bmp或jpeg文件。“Save Image”按钮已添加到ZoomInterpolation
应用程序中,用于将缩放区域保存为位图文件。“All in one View”也支持保存操作。从渲染窗口读取像素信息,并使用BMPLoader
类将其保存到BMP文件。glReadPixels()
API用于从渲染窗口读取像素信息。
以下代码用于从渲染区域检索像素信息,并将其保存为bmp文件。
void CZoomInterpolationDlg::OnBnClickedButtonSave()
{
CString csFileName;
csFileName.Format( L"ZoomInterpolation.bmp" );
CFileDialog SaveDlg( false, L"*.bmp", csFileName );
if( IDOK == SaveDlg.DoModal())
{
RECT stImageArea;
stImageArea.left =0;
stImageArea.top = 0;
stImageArea.right = 800;
stImageArea.bottom = 600;
CString csFileName = SaveDlg.GetPathName();
BYTE* pbyData = new BYTE[stImageArea.bottom * stImageArea.right * 3];
if( 0 == pbyData )
{
AfxMessageBox( L"Memory Allocation failed" );
return;
}
glReadPixels( 0, 0, stImageArea.right, stImageArea.bottom,
GL_BGR_EXT, GL_UNSIGNED_BYTE, pbyData );
BMPLoader SaveBmp;
SaveBmp.SaveBMP( csFileName, stImageArea.right, stImageArea.bottom, pbyData );
delete[] pbyData;
}
}
插值曲线的绘制
创建了一个简单的类来绘制指示双三次插值函数中应用的权重的曲线。三角形、钟形和B样条使用与GLSL着色器代码相同的代码进行绘制。此绘制显示X和Y方向的最小值和最大值。这种图形表示有助于识别应用于近像素和远像素的权重。在X方向上显示距离。在Y方向上显示距离对应的权重。在所有双三次函数中,最近的像素(距离为0时)应用最高的权重。随着距离的增加,权重会降低。
ZoomInterpolation 应用程序中使用的主类
BMPLoader
:此类用于获取图像文件的RGB信息。此类支持bmp、jpg、png和tga格式的文件。GDI+类用于加载不同的图像文件格式。GLExtension
:此类包含像素着色器所需的所有OpenGL扩展。GLSetup
:此类用于创建渲染上下文和其他OpenGL初始化。GLVertexBuffer
:此类保存渲染具有纹理映射的Quad图像所需的顶点数据。PlotInterpCurve
:此类借助GDI绘制当前的插值曲线。ZoomInterpolationDlg
:此类处理ZoomInterpolation
应用程序的主要操作。
所有鼠标移动和按钮处理都实现在此类中。此应用程序中的所有其他类都由此类使用。GLImageView
:此类用于在一个帧中创建不同的插值图像。GLText
:此类用于在“All In One”视图中显示文本。此类使用FontEngine
类。
全部在一个视图中
dan.g. 在本文中添加了评论,以准备不同类型插值的组合图像视图。这种视图有助于比较不同的插值。我们可以用一张图像比较不同插值方法的质量。我创建了一个GLImageView
类来在特定窗口区域显示缩放图像。创建了7个GLImageView
类对象,并将不同的着色器程序[GLSL Linear, GLSL BiCubic Triangular 等]提供给这些GLImageView
对象,以实现不同类型的插值。GLText
类用于在图像区域显示插值类型。感谢dan.g.的建议,他帮助我创建了这样的视图。
准备不同类型插值的代码。
void CZoomInterpolationDlg::PrepareAllInOneView()
{
...............
int nViewPortXDiv = 800 / 3;
int nViewPortYDiv = 600 / 3;
int nResourceId = IDR_GLSL_SHADER_BILINEAR;
for( int nI = 0; nI < 7; nI++ )
{
int nX = nI % 3;
int nY = nI / 3;
GLText* pText = new GLText( &m_FontEngine );
pText->SetText( INTERPOLATION_NAMES[nI] );
GLImageView* pImage = new GLImageView();
pImage->SetViewport( nX * nViewPortXDiv, nY * nViewPortYDiv,
nViewPortXDiv, nViewPortYDiv );
pImage->SetText( pText );
GLenum eTextureFilter;
if( nI >= 2 )
{
GLSLShader* pTempShader = new GLSLShader();
pTempShader->CreateProgram( nResourceId++, GL_FRAGMENT_PROGRAM_ARB );
pImage->SetShader( pTempShader, true );
eTextureFilter = GL_NEAREST;
}
else
{
pImage->SetShader( 0 ); // Shader is not required for openGL interpolation
if( 0 == nI )
{
eTextureFilter = GL_NEAREST; // Special Handling for OpenGL Interpolation
}
else
{
eTextureFilter = GL_LINEAR; // Special Handling for OpenGL Interpolation
}
}
pImage->SetTextureParam( &m_glTexture, m_nImageWidth,
m_nImageHeight, eTextureFilter );
pImage->SetVertexBuffer( &m_glVertexBufferZoom );
m_ImageView.push_back( pImage );
}
}
限制
- 为获得更好的输出图像,输入图像应以4:3的宽高比提供。否则将显示拉伸/变形的图像。
- 对于GLSL着色器实现,您的机器需要以下OpenGL扩展。
- a.
GL_ARB_fragment_shader
- b.
GL_ARB_vertex_shader
- 双三次和双线性插值的着色器是根据一些方程准备的,我预计此代码可能存在一些问题。 :)
如果您的机器没有支持的图形设备,此应用程序将显示使用OpenGL固定功能管线(最近邻和线性)的插值。
参考文献
- Digital Image Processing: PIKS Inside, Third Edition. William K. Pratt[ISBN: 0-471-22132-5] Section
- 4.3. Image Reconstruction Systems.
- 13.5.1. Interpolation Methods
- http://www.cambridgeincolour.com/tutorials/image-interpolation.htm
- http://en.wikipedia.org/wiki/Bilinear_interpolation
- http://en.wikipedia.org/wiki/Bicubic_interpolation
- Reconstruction Filters in Computer Graphics http://www.mentallandscape.com/Papers_siggraph88.pdf
修订历史
- 2011年8月7日:初始版本
- 2011年8月8日:添加了
ZoomInterpolation
应用程序的详细信息 - 2011年8月21日:添加了CatMull-Rom插值,带范围值的插值曲线
- 2011年8月26日:创建了“All In One”视图。为缩放图像和原始图像显示创建了大纲
- 2011年8月30日:添加了“Save Image”功能