前言
本文所描述的所有内容和算法,均未使用任何外部库,且已经在开源压缩软件PicSizer中使用
PicSizer是我独立编写的批量图片压缩软件,主要功能是实现网页图片的压缩。因此所有的算法都是优先考虑网页显示的。如果你对图片压缩感兴趣,可以前往Gitee查看源码,或者点击这里下载最新发行版。软件完全开源,大小仅不到 1 MB,可放心使用,删除后不会有残留。
线程管理
本节需要的命名空间:
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
多线程是充分利用CPU的一种方法,但是如果线程数量超出了CPU的逻辑处理器数量,就会适得其反。且大量的图形计算和IO操作也会导致程序卡顿,因此在PicSizer我选择了默认2个线程,最多10个线程
在使用C#自带的ThreadPool时,我发现即使就开一个线程,也会有严重的卡顿,因此我采用自己实现的线程池
线程池
实现线程池的具体思路是:先创建指定数量的线程,然后通过死循环不断地从一个数组中读取图片进行压缩,直到结束。
该过程非常简单,下面给出代码
//开始压缩
for (int i = 0; i < 10; i++)
{
//创建一个高优先级线程并立即执行
Thread thread = new Thread(() =>
{
//压缩图片的代码
})
{
Priority = ThreadPriority.Highest
};
//线程启动
thread.Start();
}
//压缩完毕
//其它代码
当压缩结束后,应当做一些“善后”工作,而实际情况是,10个线程刚创建玩,函数就结束了,为了让函数能够等待这10个压缩线程,我们可以使用WaitHandle,它通过创建独占资源来避免同时访问,这里我们可以利用它的“忙则等待”特性,在子线程中独占某个资源,结束后释放这些资源,而主线程就会因为资源被其它线程占用而进入等待,直到全部子线程都结束才能继续运行
private static List<WaitHandle> waitHandles = new List<WaitHandle>();
public static void StartThreadsPool()
{
//清空所有独占资源
waitHandles.Clear();
//创建10个子线程
for (int i = 0; i < 10; i++)
{
//创建一个独占资源
ManualResetEvent manual = new ManualResetEvent(false);
//添加到数组中
waitHandles.Add(manual);
//创建一个新线程
Thread thread = new Thread(() =>
{
//将独占资源传递给一个子线程
DoInThread(manual);
})
{
Priority = ThreadPriority.Normal
};
thread.Start();
}
//等待数组中的全部资源都被释放才继续执行
WaitHandle.WaitAll(waitHandles.ToArray());
//善后工作
//......
}
public static void DoInThread(ManualResetEvent manualResetEvent)
{
int index;
//获取下一站图片的序号,如果是-1则表示没有图片了
while ((index = GetNext()) != -1)
{
//压缩图片
}
//循环结束,释放资源
manualResetEvent.Set();
return;
}
线程同步
当两个线程对同一个资源进行“写”操作时,就需要考虑到线程同步问题。本文中,我们希望10个线程共用一个函数来获取下一张图片在数组里的下标,这里显然用到了“写”操作,因此需要用到线程同步,即每次仅允许一个线程访问
C#的实现方式非常简单,只需要在函数上面加上一句就行
[MethodImpl(MethodImplOptions.Synchronized)]
public static int GetIndex()
{
//获取下标
}
图片读写
本节需要的命名空间:
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
从文件读取
Bitmap bitmap = new Bitmap("文件路径");
写入到硬盘
bitmap.Save("导出路径", imageFormat);
其中imageFormat是输出的格式,注意该格式并不等同于后缀,一个“*.png”文件不一定就是PNG图片
imageFormat有多种选择,如果你想要导出BMP图片,则可以这样写
bitmap.Save(path, ImageFormat.Bmp);
内存流读写
如果想要获取输出之后的文件大小,你可以直接把Bitmap保存到磁盘里,然后读取。但是在接下来的算法里,需要大量输出文件,并且这些文件都是一次性的,频繁读写硬盘会造成硬盘寿命降低,同时效率也非常低。我们可以在内存中模拟输出文件,然后读取内存中的文件大小。
//创建一个内存流
MemoryStream memoryStream = new MemoryStream();
//把Bitmap写入到内存
bitmap.Save(memoryStream, imageFormat);
//摧毁内存流
memoryStream.Dispose();
现在我们可以定义一个函数,用它来计算Bitmap以指定格式输出到内存中的大小
public static long LengthOfBitmapInMemory(Bitmap bitmap, ImageFormat imageFormat)
{
MemoryStream memoryStream = null;
try
{
memoryStream = new MemoryStream();
bitmap.Save(memoryStream, imageFormat);
return memoryStream.Length >> 10;//此处的位移仅用于单位换算,可以去掉
}
finally
{
//及时摧毁内存流
memoryStream?.Dispose();
}
}
ICON文件结构
对于ICON的详细物理结构,可以前往微软文档查看
ICON文件主要分为:标头、数据段,像素段
标头保存了该文件的基本信息,例如文件类型、包含的图标数量(ICON里可以保存多个图标)
每个数据段都对应了一个图标,它保存着图标相关信息,例如尺寸、色域、像素的偏移
像素段保存着每个图标的具体像素值
C#自带的Icon类并不能保存到硬盘,我们需要自己按位写入,下面给出另存为Ico的代码
private static void SaveAsIcon(Bitmap bitmap, string path, byte size)
{
Image image = null;
FileStream fileStream = null;
BinaryWriter writer = null;
try
{
image = new Bitmap(bitmap, size, size);
fileStream = new FileStream(path, FileMode.Create);
writer = new BinaryWriter(fileStream);
//ICON文件标头(0x0)
writer.Write((short)0);//预留位,必须为0
writer.Write((short)1);//资源类型(1表示ICON)
writer.Write((short)1);//该文件里有几个资源
//ICON文件数据段(0x6)
writer.Write((byte)size);//宽度,偏移0x6
writer.Write((byte)size);//高度,偏移0x7
writer.Write((byte)0);//像素位数(0表示 >=8bpp)
writer.Write((byte)0);//预留位,必须为0
writer.Write((short)0);//色彩画板(我也不知道啥用)
writer.Write((short)32);//位深度,32位颜色
writer.Write((int)0);//像素段长度,目前还不知道具体长度,先用0代替
writer.Write((int)0x16);//该数据段对应的像素段偏移,由于共一张图片,所以偏移一定是0x16
//ICON文件像素段(偏移0x16)
image.Save(fileStream, ImageFormat.Png);
//现在知道了像素段的长度,所以控制指针往回移动,再次写入
writer.Seek(0xE, SeekOrigin.Begin);
//像素段长度是目前整个文件流的长度减去标头和数据段的长度,即 Length-22
writer.Write((int)fileStream.Length - 22);
}
finally
{
writer?.Dispose();
fileStream?.Dispose();
image?.Dispose();
}
}
考虑到写入的数据大部分都是固定的,所以我把文件标头和数据段保存为一个byte数组,下次只需要先写入这个数组,然后通过偏移修改相关字段的数据就可以了
//标头和数据段数组
private static readonly byte[] _ICON_HEADER = new byte[] {
0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 0, 0, 22, 0, 0, 0 };
private static void SaveAsIcon(Bitmap bitmap, string path, byte size)
{
Image image = null;
FileStream fileStream = null;
BinaryWriter writer = null;
try
{
image = new Bitmap(bitmap, size, size);
fileStream = new FileStream(path, FileMode.CreateNew);
writer = new BinaryWriter(fileStream);
//写入标头byte数组
writer.Write(_ICON_HEADER);
//写入像素段
image.Save(fileStream, ImageFormat.Png);
//偏移0x6处为图片宽度
writer.Seek(0x6, SeekOrigin.Begin);
writer.Write(size);
//偏移0x7处为图片高度
writer.Seek(0x7, SeekOrigin.Begin);
writer.Write(size);
//偏移0xE处为图片主体部分长度
writer.Seek(0xE, SeekOrigin.Begin);
writer.Write((int)fileStream.Length - 22);
}
finally
{
writer?.Dispose();
fileStream?.Dispose();
image?.Dispose();
}
}
图像预处理
本节需要的命名空间:
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
缩放
Bitmap的缩放有两种方式,最简单的方法仅需要一行代码
Bitmap bitmap = new Bitmap(oldBitmap, width, height);
缩放本身并不难,但是在实践中,我们通常不希望图片尺寸过大,也不希望过小,因为浏览器会自动放大尺寸较小的图片,造成模糊。因此我们可以设置一个基准尺寸,如果图片比它大,就缩放到和它相同的大小,否则不缩放
int LimitWidth = 1920;
int LimitHeight = 1080;
public static Bitmap Scale(Bitmap bitmap)
{
int width = bitmap.Width;
int height = bitmap.Height;
//求出比值
float widthByMin = (float)width / LimitWidth;
float heightByMin = (float)height / LimitHeight;
//求出较小者
float min = Math.Min(widthByMin, heightByMin);
//如果较小者大于1,则说明图片尺寸超过限制
if(min > 1)
{
//按照较小者来放缩,这样可以保证长和宽中有一个恰好是限制值,另一个略大于限制值
width = (int)(width / min);
height = (int)(height / min);
return new Bitmap(bitmap, width, height);
}
//图片没有被缩放,返回原图
return bitmap;
}
居中裁剪
假设图片原本的尺寸是 500×600,我们想要把他裁剪成 1000×1000的大小,则第一步应该先得到图片的裁剪区尺寸,即 500×500,然后将图片裁剪为 500×500 的大小,最后放大到 1000×1000
首先应求出限制尺寸需要被缩放的比值,这个比值实际上就是上一个代码块里的min,这里不再重复叙述
第二部是将Bitmap和比值传递到一个函数里,进行裁剪
private static Bitmap CenterCutBitmap(Bitmap bitmap, float scale)
{
//将限制尺寸乘上比值,就可以得到Bitmap的裁剪区尺寸
//width和height是bitmap上的需要裁剪的区域的宽和高
int final_width = (int)(LimitWidth * scale);
int final_height = (int)(LimitHeight * scale);
//bitmap的裁剪区域左上角位置
int left = (bitmap.Width - final_width) / 2;
int top = (bitmap.Height - final_height) / 2;
//创建一个新Bitmap,用于保存裁剪后的图片
Bitmap newBitmap = new Bitmap(LimitWidth, LimitHeight, PixelFormat.Format24bppRgb);
//在新的Bitmap上绘图
Graphics g = Graphics.FromImage(newBitmap);
//使用最高画笔品质
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.DrawImage(bitmap,
//该参数是在新Bitmap上绘图的尺寸,应当填满整个newBitmap
new Rectangle(0, 0, LimitWidth, LimitHeight),
//该参数是老Bitmap上取色的尺寸,应当只截取中间部分
new Rectangle(left, top, final_width, final_height),
GraphicsUnit.Pixel);
g.Dispose();
bitmap.Dispose();
return newBitmap;
}
压缩方法
本节需要的命名空间:
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
画质压缩
对于JPEG图片,我们可以调节它的画质,更低的画质意味着更小的体积
首先应获取编码参数
//获取JPEG的编解码器
public static ImageCodecInfo _Info_JPEG = Encoder.GetEncoderInfo("image/jpeg");
public static System.Drawing.Imaging.Encoder encoder = System.Drawing.Imaging.Encoder.Quality;
public static EncoderParameter[] parameterList = new EncoderParameter[101];
//该方法根据指定的画质返回编码信息数组,这个数组在压缩JPEG时需要用到
public static EncoderParameters GetEncoderParameters(long value)
{
EncoderParameters encoderParameters = new EncoderParameters(1);
encoderParameters.Param[0] = GetParameter(value);
return encoderParameters;
}
//该方法根据参数返回包含指定画质的编码信息,value的范围是: [0,100]
public static EncoderParameter GetParameter(long value)
{
int v = (int)value;
//为了提高性能,可以将使用过的编码信息保存起来,仅当数组中没有时才重新获取
if (parameterList[v] == null)
{
parameterList[v] = new EncoderParameter(encoder, value);
}
return parameterList[v];
}
//获取图像编解码器
public static ImageCodecInfo GetEncoderInfo(string type)
{
int j;
ImageCodecInfo[] encoders;
encoders = ImageCodecInfo.GetImageEncoders();
for (j = 0; j < encoders.Length; ++j)
{
if (encoders[j].MimeType == type)
{
return encoders[j];
}
}
return null;
}
现在我们就可以使用这个编码信息来压缩JPEG图像
public static void CompressionByValue(string file)
{
Bitmap bitmap = null;
try
{
bitmap = new Bitmap(file);
//创建一个编码信息数组并作为参数传入
EncoderParameters encoderParameters = new EncoderParameters(1);
//获取画质为50时候的编码信息
encoderParameters.Param[0] = GetParameter(50L);
//保存到硬盘
bitmap.Save("保存路径", _Info_JPEG, encoderParameters);
}
finally
{
bitmap?.Dispose();
}
}
位深度压缩
对于非JPEG类型的图片,由于其本身并没有提供可修改的参数,所以无法通过画质来减小体积,这时我们可以通过减少色域的方式
在C#中表示像素格式的类是PixelFormat,下面是4个常见的像素格式
public static PixelFormat[] pixelFormats = new PixelFormat[]
{
PixelFormat.Format8bppIndexed,
PixelFormat.Format16bppArgb1555,
PixelFormat.Format32bppArgb,
PixelFormat.Format64bppArgb
};
位深度越低,意味着储存一个像素所需的字节越少,文件体积也就越小。但是储存像素的字节少了,一个像素点能够表示的颜色范围就变少了,可能造成部分颜色显示异常,修改位深度非常简单,只需要一行代码
//用指定的位深度复制Bitmap
Bitmap newBitmap = oldBitmap.Clone(
new Rectangle(oldBitmap.Width, oldBitmap.Height),
pixelFormat);
该方法对所有图片均有效
缩放压缩
在浏览器中,我们可以通过适当地修改html标签来让图片显示为指定的尺寸,如果图片较小或较大,浏览器会自动为我们缩放。因此我们可以通过减小图片的尺寸来较小体积,而不必考虑它的实际显示效果
这种方法唯一的缺点就是放大后的图片会变模糊,但是比起位深度压缩带来的颜色异常,这种损失是可以接受的
压缩至指定大小
严格的说,压缩到指定的大小几乎是不可能的,我们所能做到的是压缩到不超过指定大小的最佳情况,对于画质压缩,位深度压缩,缩放压缩,都可以通过调节参数使其
以画质压缩为例,画质可被分为101个等级(0~100),首先创建一个数组,用于储存各个画质下的文件大小
long[] sizeList = new long[101];
通过常识可知文件大小和画质是呈正比的,所以我们可以通过二分查找的方式,来快速找到不超过给定大小的最高画质
//限定最大体积为1024KB
long LimitSize = 1024;
//使用二分查找的方式获取不超过给定值的最大画质
private static bool Compress(string file)
{
using (Bitmap bitmap = new Bitmap(file))
{
long left = 0L, right = 100L, mid = 0L;
long[] sizeList = new long[101];
//进入二分查找
while (left < right - 1)
{
//计算中间值
mid = (left + right) / 2;
//求出mid对应的文件体积
sizeList[mid] = GetBitmapSize(bitmap, mid);
//即使当前体积已经符合要求了,仍然要继续查找,因为目标是找到符合要求的最高画质
if (sizeList[mid] <= LimitSize)
{
left = mid;
}
else
{
right = mid;
}
}
//此时left就是所能选到的最高画质
if (sizeList[left] == 0)
{
sizeList[left] = GetBitmapSize(bitmap, left);
}
//left对应的文件体积仍然可能超出限制,因此要加一个判断
if (sizeList[left] <= LimitSize)
{
bitmap.Save("保存路径");
return true;
}
else
{
return false;
}
}
}
这里只给出了按画质压缩的例子,实际上对于另外两种压缩方式也是适用的。对于位深度压缩,可以将不同的像素格式列为一个数组进行查找;对于缩放压缩,可以调整缩放比为 0.01~1.00来进行查找