前言
本文所描述的所有内容和算法,均未使用任何外部库,且已经在开源压缩软件PicSizer中使用
PicSizer是我独立编写的批量图片压缩软件,主要功能是实现网页图片的压缩.因此所有的算法都是优先考虑网页显示的.如果你对图片压缩感兴趣,可以前往Gitee查看源码.软件完全开源,大小仅不到 1 MB,可放心使用,删除后不会有残留.
线程管理
本节需要的命名空间:
1 2 3
| using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading;
|
多线程是充分利用CPU的一种方法,但是如果线程数量超出了CPU的逻辑处理器数量,就会适得其反.且大量的图形计算和IO操作也会导致程序卡顿,因此在PicSizer我选择了默认2个线程,最多10个线程
在使用C#自带的ThreadPool时,我发现即使就开一个线程,也会有严重的卡顿,因此我采用自己实现的线程池
线程池
实现线程池的具体思路是:先创建指定数量的线程,然后通过死循环不断地从一个数组中读取图片进行压缩,直到结束.
该过程非常简单,下面给出代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| for (int i = 0; i < 10; i++) { Thread thread = new Thread(() => { }) { Priority = ThreadPriority.Highest }; thread.Start(); }
|
当压缩结束后,应当做一些“善后”工作,而实际情况是,10个线程刚创建玩,函数就结束了,为了让函数能够等待这10个压缩线程,我们可以使用WaitHandle,它通过创建独占资源来避免同时访问,这里我们可以利用它的“忙则等待”特性,在子线程中独占某个资源,结束后释放这些资源,而主线程就会因为资源被其它线程占用而进入等待,直到全部子线程都结束才能继续运行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| private static List<WaitHandle> waitHandles = new List<WaitHandle>(); public static void StartThreadsPool() { waitHandles.Clear(); 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; while ((index = GetNext()) != -1) { } manualResetEvent.Set(); return; }
|
线程同步
当两个线程对同一个资源进行“写”操作时,就需要考虑到线程同步问题.本文中,我们希望10个线程共用一个函数来获取下一张图片在数组里的下标,这里显然用到了“写”操作,因此需要用到线程同步,即每次仅允许一个线程访问
C#的实现方式非常简单,只需要在函数上面加上一句就行
1 2 3 4 5
| [MethodImpl(MethodImplOptions.Synchronized)] public static int GetIndex() { }
|
图片读写
本节需要的命名空间:
1 2 3 4
| using System; using System.Drawing; using System.Drawing.Imaging; using System.IO;
|
从文件读取
1
| Bitmap bitmap = new Bitmap("文件路径");
|
写入到硬盘
1
| bitmap.Save("导出路径", imageFormat);
|
其中imageFormat是输出的格式,注意该格式并不等同于后缀,一个“*.png”文件不一定就是PNG图片
imageFormat有多种选择,如果你想要导出BMP图片,则可以这样写
1
| bitmap.Save(path, ImageFormat.Bmp);
|
内存流读写
如果想要获取输出之后的文件大小,你可以直接把Bitmap保存到磁盘里,然后读取.但是在接下来的算法里,需要大量输出文件,并且这些文件都是一次性的,频繁读写硬盘会造成硬盘寿命降低,同时效率也非常低.我们可以在内存中模拟输出文件,然后读取内存中的文件大小.
1 2 3 4 5 6
| MemoryStream memoryStream = new MemoryStream();
bitmap.Save(memoryStream, imageFormat);
memoryStream.Dispose();
|
现在我们可以定义一个函数,用它来计算Bitmap以指定格式输出到内存中的大小
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| 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); writer.Write((short)0); writer.Write((short)1); writer.Write((short)1); writer.Write((byte)size); writer.Write((byte)size); writer.Write((byte)0); writer.Write((byte)0); writer.Write((short)0); writer.Write((short)32); writer.Write((int)0); writer.Write((int)0x16); image.Save(fileStream, ImageFormat.Png); writer.Seek(0xE, SeekOrigin.Begin); writer.Write((int)fileStream.Length - 22); } finally { writer?.Dispose(); fileStream?.Dispose(); image?.Dispose(); } }
|
考虑到写入的数据大部分都是固定的,所以我把文件标头和数据段保存为一个byte数组,下次只需要先写入这个数组,然后通过偏移修改相关字段的数据就可以了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| 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); writer.Write(_ICON_HEADER); image.Save(fileStream, ImageFormat.Png); writer.Seek(0x6, SeekOrigin.Begin); writer.Write(size); writer.Seek(0x7, SeekOrigin.Begin); writer.Write(size); writer.Seek(0xE, SeekOrigin.Begin); writer.Write((int)fileStream.Length - 22); } finally { writer?.Dispose(); fileStream?.Dispose(); image?.Dispose(); } }
|
图像预处理
本节需要的命名空间:
1 2 3 4
| using System; using System.Drawing; using System.Drawing.Imaging; using System.IO;
|
缩放
Bitmap的缩放有两种方式,最简单的方法仅需要一行代码
1
| Bitmap bitmap = new Bitmap(oldBitmap, width, height);
|
缩放本身并不难,但是在实践中,我们通常不希望图片尺寸过大,也不希望过小,因为浏览器会自动放大尺寸较小的图片,造成模糊.因此我们可以设置一个基准尺寸,如果图片比它大,就缩放到和它相同的大小,否则不缩放
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| 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); 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和比值传递到一个函数里,进行裁剪
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| private static Bitmap CenterCutBitmap(Bitmap bitmap, float scale) { int final_width = (int)(LimitWidth * scale); int final_height = (int)(LimitHeight * scale); int left = (bitmap.Width - final_width) / 2; int top = (bitmap.Height - final_height) / 2; Bitmap newBitmap = new Bitmap(LimitWidth, LimitHeight, PixelFormat.Format24bppRgb); Graphics g = Graphics.FromImage(newBitmap); g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; g.DrawImage(bitmap, new Rectangle(0, 0, LimitWidth, LimitHeight), new Rectangle(left, top, final_width, final_height), GraphicsUnit.Pixel); g.Dispose(); bitmap.Dispose(); return newBitmap; }
|
压缩方法
本节需要的命名空间:
1 2 3 4
| using System; using System.Drawing; using System.Drawing.Imaging; using System.IO;
|
画质压缩
对于JPEG图片,我们可以调节它的画质,更低的画质意味着更小的体积
首先应获取编码参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| 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];
public static EncoderParameters GetEncoderParameters(long value) { EncoderParameters encoderParameters = new EncoderParameters(1); encoderParameters.Param[0] = GetParameter(value); return encoderParameters; }
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图像
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| public static void CompressionByValue(string file) { Bitmap bitmap = null; try { bitmap = new Bitmap(file); EncoderParameters encoderParameters = new EncoderParameters(1); encoderParameters.Param[0] = GetParameter(50L); bitmap.Save("保存路径", _Info_JPEG, encoderParameters); } finally { bitmap?.Dispose(); } }
|
位深度压缩
对于非JPEG类型的图片,由于其本身并没有提供可修改的参数,所以无法通过画质来减小体积,这时我们可以通过减少色域的方式
在C#中表示像素格式的类是PixelFormat,下面是4个常见的像素格式
1 2 3 4 5 6 7
| public static PixelFormat[] pixelFormats = new PixelFormat[] { PixelFormat.Format8bppIndexed, PixelFormat.Format16bppArgb1555, PixelFormat.Format32bppArgb, PixelFormat.Format64bppArgb };
|
位深度越低,意味着储存一个像素所需的字节越少,文件体积也就越小.但是储存像素的字节少了,一个像素点能够表示的颜色范围就变少了,可能造成部分颜色显示异常,修改位深度非常简单,只需要一行代码
1 2 3 4
| Bitmap newBitmap = oldBitmap.Clone( new Rectangle(oldBitmap.Width, oldBitmap.Height), pixelFormat);
|
该方法对所有图片均有效
缩放压缩
在浏览器中,我们可以通过适当地修改html标签来让图片显示为指定的尺寸,如果图片较小或较大,浏览器会自动为我们缩放.因此我们可以通过减小图片的尺寸来较小体积,而不必考虑它的实际显示效果
这种方法唯一的缺点就是放大后的图片会变模糊,但是比起位深度压缩带来的颜色异常,这种损失是可以接受的
压缩至指定大小
严格的说,压缩到指定的大小几乎是不可能的,我们所能做到的是压缩到不超过指定大小的最佳情况,对于画质压缩,位深度压缩,缩放压缩,都可以通过调节参数使其
以画质压缩为例,画质可被分为101个等级(0~100),首先创建一个数组,用于储存各个画质下的文件大小
1
| long[] sizeList = new long[101];
|
通过常识可知文件大小和画质是呈正比的,所以我们可以通过二分查找的方式,来快速找到不超过给定大小的最高画质
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| 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; sizeList[mid] = GetBitmapSize(bitmap, mid); if (sizeList[mid] <= LimitSize) { left = mid; } else { right = mid; } } if (sizeList[left] == 0) { sizeList[left] = GetBitmapSize(bitmap, left); } if (sizeList[left] <= LimitSize) { bitmap.Save("保存路径"); return true; } else { return false; } } }
|
这里只给出了按画质压缩的例子,实际上对于另外两种压缩方式也是适用的.对于位深度压缩,可以将不同的像素格式列为一个数组进行查找;对于缩放压缩,可以调整缩放比为 0.01~1.00来进行查找