前往我的主页以获得更好的阅读体验C#图像压缩相关方法总结 - DearXuan的主页icon-default.png?t=M276https://blog.dearxuan.com/2022/02/07/C-%E5%9B%BE%E5%83%8F%E5%8E%8B%E7%BC%A9%E7%9B%B8%E5%85%B3%E6%96%B9%E6%B3%95%E6%80%BB%E7%BB%93/

前言

本文所描述的所有内容和算法,均未使用任何外部库,且已经在开源压缩软件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来进行查找

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐