C# 中的简单 JPEG 编码器






4.86/5 (41投票s)
使用 C# 实现的基线 JPEG 编码。
引言
JPEG 是用于编码照片、图片或其他视觉内容的最广泛使用的标准之一。但是,其内部工作原理大多被忽略。我们习惯于使用 image.Save("filename",Imaging.ImageFormat.Jpeg);
,但 image.save();
内部发生了什么对于程序员来说仍然是一个巨大的谜团。
大多数可用的 JPEG 编码器/解码器实现都是用 C/C++ 编写的。但是,由于一个项目,我的朋友“anirban”需要一个 C# JPEG 编码器。所以,我们开始编写自己的编码器。结果证明它非常复杂和困难。我们从头开始编写的编码器非常慢……有些函数的复杂度是四阶的(主要是 DCT 部分)。不知何故,我们设法从一个论坛获取了一些“C”代码。我将“C”代码的一部分转换为 C#;这是一项繁琐的工作,但由于快速 DCT (AA&N),它的运行速度非常快。
编码器在速度方面没有太多优化,但它工作正常。
本文不会详细解释这一切是如何工作的,但会概述 JPEG 的工作原理以及 JPEG 标准制定者所做的出色工作。如果您想正确理解 JPEG 标准,请访问官方 JPEG 网站或 IJG 网站。
屏幕截图
点击单独的通道图片框可在主 PictureBox 上显示单独的通道图像,点击主 PictureBox 则显示原始加载的图像。
“写入原始”使用 JPEG 编码器保存原始图像,而点击“写入当前”按钮则将主 PictureBox 上显示的图片转换为图像像素数组然后保存。后一个选项可用于保存单独的通道图像。
背景
我不是 JPEG 专家。但是,根据我所学到的,基本步骤是...
1. 颜色空间仿射变换:[R G B] -> [Y Cb Cr]
(在 CCIR 建议 601 中定义。)
所使用的 YCC 颜色空间遵循 TIFF 和 JPEG 所使用的颜色空间(Rec 601-1)
Y = 0.2989 R + 0.5866 G + 0.1145 B
Cb = -0.1687 R - 0.3312 G + 0.5000 B
Cr = 0.5000 R - 0.4183 G - 0.0816 B
RGB 值通常在 0 到 1 的范围内,或者由于它们存储为无符号单字节,因此在 0 到 255 的范围内。生成的亮度值也在 0 到 255 的范围内;色度值需要加上 127.5,以便它们可以存储在无符号字节中。
Y 或亮度是眼睛感知到的 RGB 颜色的强度。Y 的公式就像一个加权滤波器,每个光谱分量具有不同的权重:眼睛对绿色分量最敏感,其次是红色分量,最后是蓝色分量。
值 Cb 和 Cr 称为色度值,表示测量颜色细微差别和饱和度的系统中两个坐标([大约],这些值表示该颜色中有多少蓝色和多少红色)。
2. 采样
JPEG 标准考虑了眼睛似乎对颜色的亮度比对颜色的细微差别更敏感的事实。(黑白视图细胞比白天视图细胞具有更大的影响。)
因此,在大多数 JPG 中,亮度在每个像素中获取,而色度作为 2x2 像素块的平均值获取。请注意,色度不一定必须作为 2x2 块的平均值获取;它可以在每个像素中获取,但以这种方式可以实现良好的压缩效果,同时新采样图像的视觉感知几乎没有损失。
3. 电平位移
图像中的所有 8 位无符号值(Y、Cb、Cr)都进行“电平位移”:通过从其值中减去 128,将它们转换为 8 位有符号表示。
4. 分块
每个通道必须被分割成 8x8 像素块。如果一个通道的数据不表示整数数量的块,则编码器必须用某种形式的虚拟数据填充不完整块的剩余区域。用固定颜色(通常是黑色)填充边缘像素会在可见边界部分产生振铃伪影;重复边缘像素是一种减少可见边界的常用技术,但它仍然会产生伪影。我们目前用零(黑色)填充虚拟像素。
5. 8x8 离散余弦变换 (DCT)
然后将 DCT 应用于 8x8 块。
前向 DCT (FDCT) 的数学定义是
FDCT:
c(u,v) 7 7 2*x+1 2*y+1
F(u,v) = --------- * sum sum f(x,y) * cos (------- *u*PI)* cos (------ *v*PI)
4 x=0 y=0 16 16
u,v = 0,1,...,7
{ 1/2 when u=v=0
c(u,v) = { 1/sqrt(2) when u=0, v!=0
{ 1/sqrt(2) when u!=0, v=0
{ 1 otherwise
上面描述的 FDCT 公式计算量非常大,所以我们使用一种不同的、更快的 FDCT 形式。
6. 量化
量化是主要压缩发生的一个步骤;在此步骤中,整个 8x8 向量除以量化表中的值。结果,图像中的高频被移除。其原因是我们的眼睛对低频细节更敏感,而对高频细节不那么敏感。
16, 11, 10, 16, 24, 40, 51, 61,
12, 12, 14, 19, 26, 58, 60, 55,
14, 13, 16, 24, 40, 57, 69, 56,
14, 17, 22, 29, 51, 87, 80, 62,
18, 22, 37, 56, 68, 109, 103, 77,
24, 35, 55, 64, 81, 104, 113, 92,
49, 64, 78, 87, 103, 121, 120, 101,
72, 92, 95, 98, 112, 100, 103, 99
色度也存在类似的量化表。
编码器可以使用不同的量化表,但必须在 JPEG 文件中指定相同的量化表,以便进行正确的解码。
量化过程将在结果向量中产生大量零;因此,下一步的 RLC 将导致文件大小大幅减小。
量化表可以手动定义用于测试(逗号分隔的 64 个值列表)。
任何量化表都可以通过输入值来使用。
量化的影响
缩放使用以下方法完成:
static Byte[] Scale_And_ZigZag_Quantization_Table(Byte[] intable, float quant_scale)
{
Byte[] outTable = new Byte[64];
long temp;
for (Byte i = 0; i < 64; i++)
{
temp = ((long)(intable[i] * quant_scale + 50L) / 100L);
if (temp <= 0L)
temp = 1L;
if (temp > 255L)
temp = 255L;
outTable[Tables.ZigZag[i]] = (Byte)temp;
}
return outTable;
}
使用 QT-1(量化表 1)
使用 QT=1,因子 = 50 [28.9 Kb]
使用 QT=1,因子 = 900 [9.46 Kb]
使用 QT-2(量化表 2)
使用 QT=2,因子 = 50 [19.3 KB]
使用 QT=2,因子 = 900 [8.26 KB]
所以,很明显 JPEG 中的实际压缩发生在量化步骤。这是唯一有*损*的部分。稍后进行的熵编码是*无损*的。
7. 熵编码
a. Z 字形重新排序
然后以这样的 Z 字形方式遍历 8x8 块
| 0, 1, 5, 6,14,15,27,28, |
| 2, 4, 7,13,16,26,29,42, |
| 3, 8,12,17,25,30,41,43, |
| 9,11,18,24,31,40,44,53, |
| 10,19,23,32,39,45,52,54, |
| 20,22,33,38,46,51,55,60, |
| 21,34,37,47,50,56,59,61, |
| 35,36,48,49,57,58,62,63 |
正如您所看到的,首先是左上角 (0,0),然后是 (0,1) 处的值,然后是 (1,0),然后是 (2,0),(1,1),(0,2),(0,3),(1,2),(2,1),(3,0) 等。
在以Z字形遍历 8x8 矩阵之后,我们现在得到了一个包含 64 个系数(0..63)的向量。这种Z字形遍历的原因是,我们按照空间频率递增的顺序遍历 8x8 DCT 系数。
b. 游程编码
游程编码(RLE)是一种非常简单的压缩形式,其中数据的游程(即相同数据值连续出现在许多数据元素中的序列)存储为单个数据值和计数,而不是原始游程。这对于包含许多此类游程的数据最有用;这里是零。
示例数据:20,17,0,0,0,0,11,0,-10,-5,0,0,1,0,0,0, 0 , 0 ,0 , only 0,..,0
JPEG 压缩的 RLC:(0,20);(0,17);(4,11);(1,-10);(0,-5);(2,1);EOB
格式:(数字前的零,数字)
在下一步的霍夫曼编码中,数据以 4 位编码,因此连续零的数量受到限制,以防止值 15 (0xF) 被超过。
所以,在 15 个零之后,我们使用 (15,0) 表示有 16 个连续的零。
c. 霍夫曼编码
这是一个复杂的过程。我稍后会解释这个过程。
使用代码
1. 编码器实例可以通过以下方式创建:
BaseJPEGEncoder encoder = new BaseJPEGEncoder();
2. 然后应调用两个函数中的任何一个。
1. EncodeImageBufferToJpg()
public void EncodeImageBufferToJpg(Byte[, ,] ImageBuffer,
Point originalDimension, Point actualDimension,
BinaryWriter OutputStream, float Quantizer_Quality,
byte[] luminance_table, byte[] chromiance_table,
Utils.IProgress progress,
Utils.ICurrentOperation currentOperation)
2. EncodeImageToJpg()
public void EncodeImageToJpg(Image ImageToBeEncoded,
BinaryWriter OutputStream,
float Quantizer_Quality, byte[] luminance_table,
byte[] chromiance_table, Utils.IProgress progress,
Utils.ICurrentOperation currentOperation)
图像可以以缓冲(像素数组)的形式存在,该缓冲可以通过调用 Fill_Image_Buffer()
从位图获取。
byte[,,] Fill_Image_Buffer(Bitmap bmp, IProgress progress,ICurrentOperation operation);
数组定义为 Byte [宽度, 高度, 3]。第三个索引是颜色“Red = 0”、“Blue = 1”和“Green = 2”。
完整示例
Utils.ProgressUpdater progressObj = new Utils.ProgressUpdater();
Utils.CurrentOperationUpdater currentOperationObj = new Utils.CurrentOperationUpdater();
Bitmap bmp = new Bitmap("C:\\source.bmp");
byte [,,] image_array = Utils.Fill_Image_Buffer(bmp, progressObj, currentOperationObj);
Point originalDimension = new Point(bmp.Width, bmp.Height);
Point actualDimension = Utils.GetActualDimension(originalDimension);
FileStream fs = new FileStream("C:\\dest.jpg", FileMode.Create,
FileAccess.Write, FileShare.None);
BinaryWriter bw = new BinaryWriter(fs);
JpegEncoder.BaseJPEGEncoder encoder = new BaseJPEGEncoder();
encoder.EncodeImageBufferToJpg(image_array, originalDimension, actualDimension,
bw, float.Parse("50"), // Lower quality value better Image
Tables.std_luminance_qt, Tables.std_chrominance_qt,
progressObj, currentOperationObj);
其他详情
我使用接口进行进度更新,因此在调用编码函数之前,请按所示初始化对象。
Utils.ProgressUpdater progressObj = new Utils.ProgressUpdater();
Utils.CurrentOperationUpdater currentOperationObj = new Utils.CurrentOperationUpdater();
如果有人有更好的进度报告方式,我很乐意了解。
关注点
Utils.cs 中的 Fill_Image_Buffer()
和 Write_Bmp_From_Data()
使用互操作“gdi32.dll”来足够快地填充图像缓冲区。早些时候,我使用 GetPixel()
和 SetPixel()
,但它们非常慢。所以我不得不更改这些函数。
我花了两天时间才弄清楚 GetDIBits()
和 SetDIBits()
函数是如何工作的,通过编写一个 C++ .NET 程序然后将其转换为 C#。InteropGDI.cs 包含许多未使用的函数,但它们都像我编写的程序一样工作。
历史
这是第一个公开发布版本。