Summary of relevant methods of C# image compression

Posted by Jessup on Wed, 09 Feb 2022 07:51:51 +0100

preface

All the contents and algorithms described in this article do not use any external libraries, and have been used in the open source compression software PicSizer

PicSizer is a batch image compression software independently written by me. Its main function is to realize the compression of web page images. Therefore, all algorithms give priority to web page display. If you are interested in image compression, you can go to Gitee View the source code. The software is completely open source, with a size of less than 1 MB. It can be used safely, and there will be no residue after deletion.

PicSizer release

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

Thread management

Namespace required in this section:

using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;

Multithreading is a way to make full use of CPU, but if the number of threads exceeds the number of logical processors of CPU, it will be counterproductive. Moreover, a large number of graphic calculation and IO operations will also cause the program to get stuck. Therefore, in PicSizer, I chose 2 threads by default and 10 threads at most

When using the C# built-in ThreadPool, I found that even if one thread is started, there will be serious jams, so I use my own thread pool

Thread pool

The specific idea of implementing thread pool is to create a specified number of threads, and then continuously read pictures from an array through an endless loop for compression until the end.

The process is very simple, and the code is given below

//Start compression
for (int i = 0; i < 10; i++)
{
    //Create a high priority thread and execute it immediately
    Thread thread = new Thread(() =>
    {
        //Compressed picture code
    })
    {
        Priority = ThreadPriority.Highest
    };
    //Thread start
    thread.Start();
}
//Compression complete
//Other codes

When the compression is finished, we should do some "aftermath" work. The actual situation is that the function ends as soon as 10 threads are created. In order to enable the function to wait for these 10 compression threads, we can use WaitHandle, which avoids simultaneous access by creating exclusive resources. Here, we can use its "busy wait" feature to monopolize a resource in the sub threads, Release these resources after completion, and the main thread will wait because the resources are occupied by other threads until all sub threads are finished

private static List<WaitHandle> waitHandles = new List<WaitHandle>();
 
public static void StartThreadsPool()
{
    //Empty all exclusive resources
    waitHandles.Clear();
    //Create 10 child threads
    for (int i = 0; i < 10; i++)
    {
        //Create an exclusive resource
        ManualResetEvent manual = new ManualResetEvent(false);
        //Add to array
        waitHandles.Add(manual);
        //Create a new thread
        Thread thread = new Thread(() =>
        {
            //Pass exclusive resources to a child thread
            DoInThread(manual);
        })
        {
            Priority = ThreadPriority.Normal
        };
        thread.Start();
    }
    //Wait for all resources in the array to be released before continuing execution
    WaitHandle.WaitAll(waitHandles.ToArray());
    //dealing with the aftermath
    //......
}
 
public static void DoInThread(ManualResetEvent manualResetEvent)
{
    int index;
    //Get the sequence number of the next station picture. If it is - 1, it means there is no picture
    while ((index = GetNext()) != -1)
    {
        //Compressed picture
    }
    //The cycle ends and resources are released
    manualResetEvent.Set();
    return;
}

Thread synchronization

When two threads "write" to the same resource, thread synchronization needs to be considered. In this article, we want 10 threads to share a function to obtain the subscript of the next image in the array. Obviously, the "write" operation is used here, so thread synchronization is required, that is, only one thread is allowed to access at a time

The implementation of C # is very simple. You only need to add a sentence to the function

[MethodImpl(MethodImplOptions.Synchronized)]
public static int GetIndex()
{
    //Get subscript
}

Picture reading and writing

Namespace required in this section:

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

Read from file

Bitmap bitmap = new Bitmap("File path");

Write to hard disk

bitmap.Save("export path", imageFormat);

imageFormat is the output format. Note that this format is not the same as the suffix. A "*. PNG" file is not necessarily a PNG image

imageFormat has many options. If you want to export BMP images, you can write it like this

bitmap.Save(path, ImageFormat.Bmp);

Memory stream read / write

If you want to get the file size after output, you can directly save the Bitmap to disk and read it. However, in the next algorithm, a large number of output files are required, and these files are one-time. Frequent reading and writing to the hard disk will reduce the service life of the hard disk, and the efficiency is also very low. We can simulate the output file in memory and then read the file size in memory.

//Create a memory stream
MemoryStream memoryStream = new MemoryStream();
//Write Bitmap to memory
bitmap.Save(memoryStream, imageFormat);
//Destroy memory stream
memoryStream.Dispose();

Now we can define a function to calculate the size of Bitmap output to memory in the specified format

public static long LengthOfBitmapInMemory(Bitmap bitmap, ImageFormat imageFormat)
{
    MemoryStream memoryStream = null;
    try
    {
        memoryStream = new MemoryStream();
        bitmap.Save(memoryStream, imageFormat);
        return memoryStream.Length >> 10;//The displacement here is only used for unit conversion and can be removed
    }
    finally
    {
        //Destroy the memory stream in time
        memoryStream?.Dispose();
    }
}

ICON file structure

For the detailed physical structure of ICON, you can go to Microsoft Document view

ICON files are mainly divided into header, data segment and pixel segment

The header saves the basic information of the file, such as the file type and the number of icons contained (multiple icons can be saved in ICON)

Each data segment corresponds to an icon, which stores icon related information, such as size, color gamut and pixel offset

The pixel segment holds the specific pixel value of each icon

C # built-in Icon class cannot be saved to the hard disk. We need to write it by bit. The code saved as Ico is given below

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 file header (0x0)
        writer.Write((short)0);//Reserved bit, must be 0
        writer.Write((short)1);//Resource type (1 for ICON)
        writer.Write((short)1);//There are several resources in this document
        
        //ICON file data segment (0x6)
        writer.Write((byte)size);//Width, offset 0x6
        writer.Write((byte)size);//Height, offset 0x7
        writer.Write((byte)0);//Number of pixels (0 means > = 8bpp)
        writer.Write((byte)0);//Reserved bit, must be 0
        writer.Write((short)0);//Color palette (I don't know what to use)
        writer.Write((short)32);//Bit depth, 32-bit color
        writer.Write((int)0);//Pixel segment length. The specific length is not known yet. Replace it with 0 first
        writer.Write((int)0x16);//The pixel segment offset corresponding to the data segment must be 0x16 because there is one picture in total
        
        //ICON file pixel segment (offset 0x16)
        image.Save(fileStream, ImageFormat.Png);
 
        //Now that the length of the pixel segment is known, control the pointer to move back and write again
        writer.Seek(0xE, SeekOrigin.Begin);
        //The length of pixel segment is the length of the current entire file stream minus the length of header and data segment, i.e. Length-22
        writer.Write((int)fileStream.Length - 22);
    }
    finally
    {
        writer?.Dispose();
        fileStream?.Dispose();
        image?.Dispose();
    }
}

Considering that most of the written data are fixed, I save the file header and data segment as a byte array. Next time, I just need to write this array first, and then modify the data of relevant fields through offset

//Header and segment array
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);
        
        //Write header byte array
        writer.Write(_ICON_HEADER);
        
        //Write pixel segment
        image.Save(fileStream, ImageFormat.Png);
        
        //The offset 0x6 is the width of the picture
        writer.Seek(0x6, SeekOrigin.Begin);
        writer.Write(size);
        
        //The offset 0x7 is the height of the picture
        writer.Seek(0x7, SeekOrigin.Begin);
        writer.Write(size);
        
        //The offset 0xE is the length of the main part of the picture
        writer.Seek(0xE, SeekOrigin.Begin);
        writer.Write((int)fileStream.Length - 22);
    }
    finally
    {
        writer?.Dispose();
        fileStream?.Dispose();
        image?.Dispose();
    }
}

Image preprocessing

Namespace required in this section:

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

zoom

There are two ways to scale Bitmap. The simplest method requires only one line of code

Bitmap bitmap = new Bitmap(oldBitmap, width, height);

Scaling itself is not difficult, but in practice, we usually don't want the picture to be too large or too small, because the browser will automatically enlarge the picture with smaller size, resulting in blur. Therefore, we can set a benchmark size. If the picture is larger than it, it will be reduced to the same size as it. Otherwise, it will not be scaled

int LimitWidth = 1920;
int LimitHeight = 1080;
 
public static Bitmap Scale(Bitmap bitmap)
{
    int width = bitmap.Width;
    int height = bitmap.Height;
 
    //Calculate the ratio
    float widthByMin = (float)width / LimitWidth;
    float heightByMin = (float)height / LimitHeight;
 
    //Find the smaller one
    float min = Math.Min(widthByMin, heightByMin);
 
    //If the smaller one is greater than 1, the picture size exceeds the limit
    if(min > 1)
    {
        //Zoom in and out according to the smaller one, so as to ensure that one of the length and width is exactly the limit value and the other is slightly larger than the limit value
        width = (int)(width / min);
        height = (int)(height / min);
        return new Bitmap(bitmap, width, height);
    }
 
    //The picture is not zoomed. Return to the original picture
    return bitmap;
}

Center crop

Suppose the original size of the picture is 500 × 600, we want to cut it into 1000 × If the size is 1000, the first step is to get the size of the clipping area of the picture, i.e. 500 × 500, then cut the picture to 500 × 500, and finally zoom in to 1000 × one thousand

First of all, we should calculate the ratio that the size of the restriction needs to be scaled. This ratio is actually the min in the previous code block, which will not be repeated here

The second part is to transfer the Bitmap and ratio to a function for clipping

private static Bitmap CenterCutBitmap(Bitmap bitmap, float scale)
{
    //Multiply the limiting size by the ratio to get the clipping area size of Bitmap
    //Width and height are the width and height of the area to be cropped on the bitmap
    int final_width = (int)(LimitWidth * scale);
    int final_height = (int)(LimitHeight * scale);
 
    //The upper left corner of the clipping region of the bitmap
    int left = (bitmap.Width - final_width) / 2;
    int top = (bitmap.Height - final_height) / 2;
 
    //Create a new Bitmap to save the cropped image
    Bitmap newBitmap = new Bitmap(LimitWidth, LimitHeight, PixelFormat.Format24bppRgb);
 
    //Draw on a new Bitmap
    Graphics g = Graphics.FromImage(newBitmap);
    //Use the highest brush quality
    g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
    g.DrawImage(bitmap,
        //This parameter is the size of drawing on the new Bitmap and should fill the whole newBitmap
        new Rectangle(0, 0, LimitWidth, LimitHeight),
 
        //This parameter is the size of the color taken on the old Bitmap, and only the middle part should be intercepted
        new Rectangle(left, top, final_width, final_height),
        GraphicsUnit.Pixel);
    g.Dispose();
    bitmap.Dispose();
    return newBitmap;
}

Compression method

Namespace required in this section:

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

Image quality compression

For JPEG images, we can adjust its image quality. Lower image quality means smaller volume

First, get the coding parameters

//Get JPEG codec
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];
 
//This method returns an array of encoding information according to the specified image quality. This array needs to be used when compressing JPEG
public static EncoderParameters GetEncoderParameters(long value)
{
    EncoderParameters encoderParameters = new EncoderParameters(1);
    encoderParameters.Param[0] = GetParameter(value);
    return encoderParameters;
}
 
//This method returns the encoding information containing the specified image quality according to the parameters. The range of value is: [0100]
public static EncoderParameter GetParameter(long value)
{
    int v = (int)value;
    //In order to improve performance, you can save the used coding information and retrieve it only when there is no in the array
    if (parameterList[v] == null)
    {
        parameterList[v] = new EncoderParameter(encoder, value);
    }
    return parameterList[v];
}
 
//Get image codec
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;
}

Now we can use this encoding information to compress JPEG images

public static void CompressionByValue(string file)
{
    Bitmap bitmap = null;
    try
    {
        bitmap = new Bitmap(file);
        //Create an array of encoding information and pass it in as a parameter
        EncoderParameters encoderParameters = new EncoderParameters(1);
        //Obtain the coding information when the picture quality is 50
        encoderParameters.Param[0] = GetParameter(50L);
        //Save to hard disk
        bitmap.Save("Save path", _Info_JPEG, encoderParameters);
    }
    finally
    {
        bitmap?.Dispose();
    }
}

Bit depth compression

For non JPEG images, because they do not provide modifiable parameters, they cannot reduce the volume through image quality. At this time, we can reduce the color gamut

The class representing pixel format in C# is PixelFormat. Here are four common pixel formats

public static PixelFormat[] pixelFormats = new PixelFormat[]
{
    PixelFormat.Format8bppIndexed,
    PixelFormat.Format16bppArgb1555,
    PixelFormat.Format32bppArgb,
    PixelFormat.Format64bppArgb
};

The lower the bit depth, the smaller the number of bytes required to store a pixel and the smaller the file size. However, if there are fewer bytes to store pixels, the color range that a pixel can represent becomes less, which may cause some color display abnormalities. Modifying the bit depth is very simple, and only one line of code is required

//Copies the Bitmap with the specified bit depth
Bitmap newBitmap = oldBitmap.Clone(
    new Rectangle(oldBitmap.Width, oldBitmap.Height), 
    pixelFormat);

This method is effective for all pictures

Zoom compression

In the browser, we can modify the html tag appropriately to make the picture display to the specified size. If the picture is small or large, the browser will automatically zoom for us. Therefore, we can reduce the size of the picture to reduce the volume without considering its actual display effect

The only disadvantage of this method is that the enlarged image will become blurred, but compared with the color anomaly caused by bit depth compression, this loss is acceptable

Compress to specified size

Strictly speaking, it is almost impossible to compress to the specified size. What we can do is to compress to the best condition that it does not exceed the specified size. For image quality compression, bit depth compression and scaling compression, we can make it by adjusting parameters

Taking image quality compression as an example, the image quality can be divided into 101 levels (0 ~ 100). First, create an array to store the file size under each image quality

long[] sizeList = new long[101];

Common sense shows that the file size is directly proportional to the image quality, so we can quickly find the highest image quality that does not exceed a given size through binary search

//The maximum volume is 1024KB
long LimitSize = 1024;
 
//Use binary search to obtain the maximum image quality that does not exceed the given value
private static bool Compress(string file)
{
    using (Bitmap bitmap = new Bitmap(file))
    {
        long left = 0L, right = 100L, mid = 0L;
        long[] sizeList = new long[101];
        //Enter binary search
        while (left < right - 1)
        {
            //Calculate intermediate value
            mid = (left + right) / 2;
            //Find the file volume corresponding to mid
            sizeList[mid] = GetBitmapSize(bitmap, mid);
            //Even if the current volume meets the requirements, continue to search, because the goal is to find the highest image quality that meets the requirements
            if (sizeList[mid] <= LimitSize)
            {
                left = mid;
            }
            else
            {
                right = mid;
            }
        }
        //At this time, left is the highest image quality that can be selected
        if (sizeList[left] == 0)
        {
            sizeList[left] = GetBitmapSize(bitmap, left);
        }
        //The file volume corresponding to left may still exceed the limit, so we need to add a judgment
        if (sizeList[left] <= LimitSize)
        {
            bitmap.Save("Save path");
            return true;
        }
        else
        {
            return false;
        }
    }
}

Here is only an example of image quality compression. In fact, it is also applicable to the other two compression methods. For bit depth compression, different pixel formats can be listed as an array for searching; For scaling compression, you can adjust the scaling ratio to 0.01 ~ 1.00 to find