前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >C#图像压缩相关方法总结

C#图像压缩相关方法总结

作者头像
DearXuan
发布2022-02-08 08:08:17
9800
发布2022-02-08 08:08:17
举报
文章被收录于专栏:DearXuan的博客文章

前言

本文所描述的所有内容和算法,均未使用任何外部库,且已经在开源压缩软件PicSizer中使用

PicSizer是我独立编写的批量图片压缩软件,主要功能是实现网页图片的压缩。因此所有的算法都是优先考虑网页显示的。如果你对图片压缩感兴趣,可以前往Gitee查看源码。软件完全开源,大小仅不到 1 MB,可放心使用,删除后不会有残留。

PicSizer发行版

https://gitee.com/dearxuan/pic-sizer/releases

线程管理

本节需要的命名空间:

代码语言:javascript
复制
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;

多线程是充分利用CPU的一种方法,但是如果线程数量超出了CPU的逻辑处理器数量,就会适得其反。且大量的图形计算和IO操作也会导致程序卡顿,因此在PicSizer我选择了默认2个线程,最多10个线程

在使用C#自带的ThreadPool时,我发现即使就开一个线程,也会有严重的卡顿,因此我采用自己实现的线程池

线程池

实现线程池的具体思路是:先创建指定数量的线程,然后通过死循环不断地从一个数组中读取图片进行压缩,直到结束。

该过程非常简单,下面给出代码

代码语言:javascript
复制
//开始压缩
for (int i = 0; i < 10; i++)
{
    //创建一个高优先级线程并立即执行
    Thread thread = new Thread(() =>
    {
        //压缩图片的代码
    })
    {
        Priority = ThreadPriority.Highest
    };
    //线程启动
    thread.Start();
}
//压缩完毕
//其它代码

当压缩结束后,应当做一些“善后”工作,而实际情况是,10个线程刚创建玩,函数就结束了,为了让函数能够等待这10个压缩线程,我们可以使用WaitHandle,它通过创建独占资源来避免同时访问,这里我们可以利用它的“忙则等待”特性,在子线程中独占某个资源,结束后释放这些资源,而主线程就会因为资源被其它线程占用而进入等待,直到全部子线程都结束才能继续运行

代码语言:javascript
复制
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#的实现方式非常简单,只需要在函数上面加上一句就行

代码语言:javascript
复制
[MethodImpl(MethodImplOptions.Synchronized)]
public static int GetIndex()
{
    //获取下标
}

图片读写

本节需要的命名空间:

代码语言:javascript
复制
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

从文件读取

代码语言:javascript
复制
Bitmap bitmap = new Bitmap("文件路径");

写入到硬盘

代码语言:javascript
复制
bitmap.Save("导出路径", imageFormat);

其中imageFormat是输出的格式,注意该格式并不等同于后缀,一个“*.png”文件不一定就是PNG图片

imageFormat有多种选择,如果你想要导出BMP图片,则可以这样写

代码语言:javascript
复制
bitmap.Save(path, ImageFormat.Bmp);

内存流读写

如果想要获取输出之后的文件大小,你可以直接把Bitmap保存到磁盘里,然后读取。但是在接下来的算法里,需要大量输出文件,并且这些文件都是一次性的,频繁读写硬盘会造成硬盘寿命降低,同时效率也非常低。我们可以在内存中模拟输出文件,然后读取内存中的文件大小。

代码语言:javascript
复制
//创建一个内存流
MemoryStream memoryStream = new MemoryStream();
//把Bitmap写入到内存
bitmap.Save(memoryStream, imageFormat);
//摧毁内存流
memoryStream.Dispose();

现在我们可以定义一个函数,用它来计算Bitmap以指定格式输出到内存中的大小

代码语言:javascript
复制
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的代码

代码语言:javascript
复制
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数组,下次只需要先写入这个数组,然后通过偏移修改相关字段的数据就可以了

代码语言:javascript
复制
//标头和数据段数组
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();
    }
}

图像预处理

本节需要的命名空间:

代码语言:javascript
复制
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

缩放

Bitmap的缩放有两种方式,最简单的方法仅需要一行代码

代码语言:javascript
复制
Bitmap bitmap = new Bitmap(oldBitmap, width, height);

缩放本身并不难,但是在实践中,我们通常不希望图片尺寸过大,也不希望过小,因为浏览器会自动放大尺寸较小的图片,造成模糊。因此我们可以设置一个基准尺寸,如果图片比它大,就缩放到和它相同的大小,否则不缩放

代码语言:javascript
复制
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和比值传递到一个函数里,进行裁剪

代码语言:javascript
复制
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;
}

压缩方法

本节需要的命名空间:

代码语言:javascript
复制
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

画质压缩

对于JPEG图片,我们可以调节它的画质,更低的画质意味着更小的体积

首先应获取编码参数

代码语言:javascript
复制
//获取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图像

代码语言:javascript
复制
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个常见的像素格式

代码语言:javascript
复制
public static PixelFormat[] pixelFormats = new PixelFormat[]
{
    PixelFormat.Format8bppIndexed,
    PixelFormat.Format16bppArgb1555,
    PixelFormat.Format32bppArgb,
    PixelFormat.Format64bppArgb
};

位深度越低,意味着储存一个像素所需的字节越少,文件体积也就越小。但是储存像素的字节少了,一个像素点能够表示的颜色范围就变少了,可能造成部分颜色显示异常,修改位深度非常简单,只需要一行代码

代码语言:javascript
复制
//用指定的位深度复制Bitmap
Bitmap newBitmap = oldBitmap.Clone(
    new Rectangle(oldBitmap.Width, oldBitmap.Height), 
    pixelFormat);

该方法对所有图片均有效

缩放压缩

在浏览器中,我们可以通过适当地修改html标签来让图片显示为指定的尺寸,如果图片较小或较大,浏览器会自动为我们缩放。因此我们可以通过减小图片的尺寸来较小体积,而不必考虑它的实际显示效果

这种方法唯一的缺点就是放大后的图片会变模糊,但是比起位深度压缩带来的颜色异常,这种损失是可以接受的

压缩至指定大小

严格的说,压缩到指定的大小几乎是不可能的,我们所能做到的是压缩到不超过指定大小的最佳情况,对于画质压缩,位深度压缩,缩放压缩,都可以通过调节参数使其

以画质压缩为例,画质可被分为101个等级(0~100),首先创建一个数组,用于储存各个画质下的文件大小

代码语言:javascript
复制
long[] sizeList = new long[101];

通过常识可知文件大小和画质是呈正比的,所以我们可以通过二分查找的方式,来快速找到不超过给定大小的最高画质

代码语言:javascript
复制
//限定最大体积为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来进行查找

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022年2月7日,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 线程管理
    • 线程池
      • 线程同步
      • 图片读写
        • 从文件读取
          • 写入到硬盘
            • 内存流读写
              • ICON文件结构
              • 图像预处理
                • 缩放
                  • 居中裁剪
                  • 压缩方法
                    • 画质压缩
                      • 位深度压缩
                        • 缩放压缩
                          • 压缩至指定大小
                          相关产品与服务
                          文件存储
                          文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
                          领券
                          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档