一、实验原理
1. BMP文件的组成
BMP文件由四部分组成:位图文件头数据、位图信息头数据、调色板和位图数据。下面结合具体图片说明。为了方便在查看数据项在图片里的具体内容,每行前面加了当前项在文件中的地址值。
(1)文件头 BITMAP_FILE_HEADER,包含如下内容
typedef struct tagBITMAPFILEHEADER {
//0x00~0x01,说明文件的类型
WORD bfType;
//0x02~0x05,说明文件的大小,用字节B为单位
DWORD bfSize;
//0x06~0x07,保留,设置为0
WORD bfReserved1;
//0x08~0x09,保留,设置为0
WORD bfReserved2;
//0x0a~0x0d,说明从BITMAP_FILE_HEADER结构开始到实际的图像数据之间的字节偏移量
DWORD bfOffBits;
} BITMAPFILEHEADER;
(2)信息头BITMAP_INFO_HEADER,包含如下内容
typedef struct tagBITMAPINFOHEADER {
//0x0e~0x11,说明当前结构体所需字节数
DWORD biSize;
//0x12~0x15,以像素为单位说明图像的宽度
LONG biWidth;
//0x16~0x19,以像素为单位说明图像的高度
LONG biHeight;
//0x1a~0x1b,说明位面数,必须为1
WORD biPlanes;
//0x1c~0x1d,说明图像的位深度
WORD biBitCount;
//0x1e~0x21,说明图像是否压缩及压缩类型
DWORD biCompression;
//0x22~0x25,以字节为单位说明图像大小,必须是4的整数倍
DWORD biSizeImage;
//0x26~0x29,目标设备的水平分辨率,像素/米
LONG biXPelsPerMeter;
//0x2a~0x2d,目标设备的垂直分辨率,像素/米
LONG biYPelsPerMeter;
//0x2e~0x31,说明图像实际用到的颜色数,如果为0,则颜色数为2的biBitCount次方
DWORD biClrUsed;
//0x32~0x35,说明对图像显示有重要影响的颜色索引的数目,如果是0,表示都重要。
DWORD biClrImportant;
} BITMAPINFOHEADER;
(3)调色板Palette。当图像位深度小于等于8时称为索引颜色,这时整个文件不保存每个像素的RGB值,而是把图像中的所有颜色编制成颜色表。在表示图片中每个像素的颜色信息时,使用颜色表的索引。由于位深度不超过8位,颜色表不超过256个数值。
调色板决定于位于0x2e~0x31的biClrUsed字段和位于0x1c~0x1d的biBitCount字段,每个元素的类型是一个 RGBQUAD 结构,顺序为B,G,R及保留字,每一个元素占用一个字节。
typedef struct tagRGBQUAD {
BYTE rgbBlue; /*指定蓝色分量*/
BYTE rgbGreen; /*指定绿色分量*/
BYTE rgbRed; /*指定红色分量*/
BYTE rgbReserved; /*保留,指定为0*/
} RGBQUAD;
(4)位图数据ImageData。对于用到调色板的位图,图像数据就是该像素颜色在调色板中的索引值。对于真彩色图,图像数据就是实际的RGB值。
图像的每一扫描行由表示图像像素的连续的字节组成,每一行的字节数取决于图像的颜色数目和用像素表示的图像宽度。规定每一扫描行的字节数必须是 4 的整倍数,也就是DWORD 对齐的。扫描行是由底向上存储的,这就是说,阵列中的第一个字节表示位图左下角的像素,而最后一个字节表示位图右上角的像素。
表1是两幅具体图片及其文件内容,表格里只列出了文件头和信息头中不同部分的值。
二进制方式打开 | ||
图像基本信息 | 分辨率:1280×720 位深度:8位索引颜色 文件大小:901KB=922,680B | 分辨率:1280×1280 位深度:24位真彩色 文件大小:4.68MB=4,915,256B |
0x0002~0x0005 | 38 14 0e 00 | 38 00 4b 00 |
0x000a~0x000d | 36 04 00 00 | 36 00 00 00 |
0x0016~0x0019 | d0 02 00 00 | 00 05 00 00 |
0x001c~0x001d | 08 00 | 18 00 |
0x0022~0x0025 | 02 10 0e 00 | 02 00 4b 00 |
- 0x00~0x01,标记了此文件为BMP文件,内容是B和M两个字母的ASCII码。
- 0x02~0x05,以字节为单位的文件大小。左图大小为922,680字节,写成十六进制就是E1438。在这里可以看出BMP文件的小端保存方式,先保存低位字节,再保存高位字节,保存顺序为38、14、0e。同理,右图为4,915,256(4B0038H)个字节,保存顺序为38、00、4b。
- 0x0a~0x0d,实际数据的字节偏移量。BMP文件共有的前两部分文件头占了54(36H)个字节,左图包含了8位也就是256种索引颜色的调色板,调色板中每个颜色的RGBQUAD结构占用4字节,总共1024个字节的调色板信息。因此左图的偏移量为54+1024=1078(436H)个字节。
- 0x16~0x19,左图高度为720(2D0H)个像素,右图高度为1280(500H)个像素。
- 0x1c~0x1d,左图位深度为8(8H),右图位深度为24(18H)。
- 0x22~0x25,BMP图像大小biSizeImage可由下式计算
biSizeImage=⌊cx×biBitCount+3132⌋×4×cy+2其中, cx,cy 表示水平和垂直方向的像素数。 cx×biBitCount 表示一行图像占了多少位。BMP规定这个图像大小必须是4字节的整数倍,也就是32位的整数倍,因此需要把 cx×biBitCount 加31再除以32后下取整,就保证了计算结果是离这个数最近的而且是比它大的32的倍数,也就保证了是4字节的整数倍。乘以4和行数,得到4字节整数倍的图像大小。
另外,BMP文件的末尾两个字节是保留位,无论图像是什么这两个字节都为0,因此最后计算结果还要加上2字节。图像大小biSizeImage+字节偏移量bfOffBits=文件大小bfSize。左图的图像大小经过计算为921,602(E1002H)字节,右图图像大小为4,915,202(4B0002H)字节。 - 0x26~0x29,水平分辨率。两幅图分辨率都是计算机上常见的72dpi,也就是72像素/英寸=28.346像素/厘米。以像素/米单位表示就是2834(B12H)。后面四个字节的垂直分辨率同理。
2.位深度的各种存储方式
BMP文件的位深度有以下几种:1,2,4,8,16,24,32。其中1,2,4,8位对应索引颜色,图像包含调色板数据。16,24,32位直接保存像素的颜色值,这三种位深度的像素保存方式有多种,见图1。
3.颜色转换公式
二、实验流程及代码分析
1. 主函数的流程
BMP转YUV可由以下几步构成
下面是关键代码分析,代码说明在每段代码的下方。
//--------main_bmp2yuv.cpp--------
//头文件,读取命令行参数,打开文件与之前类似,略过
#include <windows.h>
...
//读取BMP文件头,信息头,读取错误时的处理代码略过
BITMAPFILEHEADER file_header;
BITMAPINFOHEADER info_header;
if (fread(&file_header, sizeof(BITMAPFILEHEADER), 1, bmpFile) != 1)
...
if (file_header.bfType != 0x4D42)
{
printf("Not BMP file.\n");
exit(1);
}
if (fread(&info_header, sizeof(BITMAPINFOHEADER), 1, bmpFile) != 1)
...
//读取图像尺寸
int width = info_header.biWidth;
int height = info_header.biHeight;
BMP文件的结构组成包含在windows.h中,可以使用BITMAPFILEHEADER和BITMAPINFOHEADER两种数据类型方便地对文件头和信息头进行组织,这两种数据类型包含了第一部分中的全部属性。另外还有RGBQUAD类型,用来组织调色板。
首先读取文件头file_header,如果前两字节不是42H和4DH(B字母和M字母的ASCII码)说明不是BMP文件,退出。读完文件头之后文件指针就指向了信息头的起始,这时就可以进行读取信息头info_header的操作。读完两部分数据之后,使用信息头的biWidth和biHeight属性读取图像宽高信息,为下一步创建缓冲区做准备。
//开辟缓冲区,yuv缓冲区部分略过
unsigned char* rgbBuf = (unsigned char*)malloc(width*height * 3);
...
//BMP与RGB的转换,得到RGB数据
if (BMP2RGB(file_header, info_header, bmpFile, rgbBuf))
{
printf("BMP2RGB error\n");
exit(1);
}
//RGB与YUV的转换,得到YUV数据
int flip = 0;
if (RGB2YUV(width, height, rgbBuf, yBuf, uBuf, vBuf, flip))
{
printf("RGB2YUV error\n");
exit(1);
}
//之后与RGB转YUV类似,例如对超出电平值的处理,关闭缓冲区等,略过
...
BMP文件先通过BMP2RGB函数转成RGB数据,再通过RGB2YUV函数转成YUV数据。读取到的图像数据是倒序存放的,flip=0保证了RGB2YUV可以正确地对其转换,具体会在后面的分析中说明。下面开始分析BMP2RGB函数的具体细节。
2. BMP2YUV函数的流程
//--------bmp2rgb.cpp--------
//头文件,函数声明等略过
...
//判断像素的实际点阵数
int w, h;
w = (info_h.biWidth*info_h.biBitCount + 31) / 32 * 4;//w为实际一行的字节数
h = info_h.biHeight;//h为列数
//开辟实际字节数量的缓冲区,读数据,一次读取一个字节
unsigned char* dataBuf = (unsigned char*)malloc(w*h);
fseek(pFile, file_h.bfOffBits, 0);
fread(dataBuf, 1, w*h, pFile);
unsigned char* data = dataBuf;
unsigned char* rgb = rgbBuf;
在BMP文件组成中说到,BMP文件规定一行的字节数必须是4字节的倍数,如果不是要补充相应的位数使之成为4的倍数。因此实际的字节数可能比像素数×位深度要多。使用上面说到的计算biSizeImage的公式可以得到实际每行有多少字节。由于整个文件最后两字节为保留字节,与获取像素值无关,因此这里w最后没有加2。
得到了实际存储的字节数后就可以开辟数据缓冲区了,使用文件头的字节偏移属性bfOffBits直接把文件指针定位到像素值数据的起始,开始读取数据,一次读取一个字节。
之后就是要根据量化位数的不同进行不同的操作,下面分别对其进行说明。
(1)8位及以下索引文件的读取
对于索引颜色,读取流程如下
//调色板的字节数的初始值colorCnt
int colorCnt = pow(2, (float)info_h.biBitCount);
//如果实际用到的颜色不为0,则以biClrUsed为准
if (info_h.biClrUsed != 0)
colorCnt = info_h.biClrUsed;
//分配调色板缓冲区
RGBQUAD* pRGBBuf = (RGBQUAD*)malloc(sizeof(RGBQUAD)*colorCnt);
if (pRGBBuf == NULL)
{
printf("Not enough memory\n");
return 1;
}
//调色板与文件起始处相距(文件头 + 信息头)个字节
fseek(pFile, sizeof(BITMAPFILEHEADER) + info_h.biSize, 0);
//读调色板数据
fread(pRGBBuf, sizeof(RGBQUAD), colorCnt, pFile);
RGBQUAD* pRGB = pRGBBuf;
索引颜色都有调色板,也就是索引值表。因此首先要把调色板保存下来。这里与课件中创建调色板的代码略有不同:
RGBQUAD *pRGB = (RGBQUAD *)malloc(sizeof(RGBQUAD)*(unsigned char)pow(2,info_h.biBitCount));
...
fread(pRGB_out,sizeof(RGBQUAD),(unsigned int)pow(2,info_h.biBitCount),pFile);
课件中调色板的大小设为了2的biBitCount次方,一个n比特量化的BMP图像调色板的实际颜色数取决于biClrUsed的值。图2展示了这样一个例子。这是一幅30×30像素的图像,图像在右下角,4bit量化,但是整幅图里只有黑白灰三种颜色。
从图2中也可以看出图像的存储方式是由底向上存储的。索引值表中第0个颜色是白色,第1个颜色是灰色,第2个颜色是黑色。0x42中前4位的值为2,说明存储的是黑色。图像左下角像素为黑色,左上角像素为白色,因此扫描行是由底向上存储的。
得到了调色板颜色数目colorCnt之后把文件指针移动到调色板的起始位置,也就是文件头加信息头个字节后,把调色板读取到缓冲区内。
for (j = 0; j < h; j++)//j控制行循环
{
int pixCnt = 0;//pixCnt用于判断一行是否结束
for (i = 0; i < w; i++)//i控制列循环
{
unsigned char mask;
//根据不同的量化位数计算蒙版值
switch (info_h.biBitCount)
{
case 1:
mask = 0x80;//1000 0000
break;
case 2:
mask = 0xC0;//1100 0000
break;
case 4:
mask = 0xF0;//1111 0000
break;
case 8:
mask = 0xFF;//1111 1111
break;
}
...
由于读取文件的缓冲区data是一个字节一个字节读取的,但是只有8位量化的索引值才足够一个字节,1,2,4位的索引值都不足一个字节,也就是一个字节里包含了多个像素的信息。因此在读取像素索引值时需要一个“蒙版”遮住不需要的索引值,读完当前像素值把该蒙版向后移动相应的位数,显示出下一个像素值的信息。“遮住”的具体操作就是与运算,把不需要的位与0,需要的位与1,根据这个规则,计算出蒙版值。
对于1位的图像,一个字节里的第一位为第一个像素的值,因此蒙版值为1000 0000,也就是0x80。2,4,8位的图像同理。
int shiftCnt = 1;//shiftCnt控制蒙版的移位
while (mask)
{
//通过移位操作获取当前像素的索引值
unsigned char index = mask == 0xFF ?
data[i + w*j] : ((data[i + w*j] & mask) >> (8 - shiftCnt * info_h.biBitCount));
*rgb = pRGB[index].rgbBlue;//B
*(rgb + 1) = pRGB[index].rgbGreen;//G
*(rgb + 2) = pRGB[index].rgbRed;//R
//如果是8位,开始读下一个字节,如果不是继续读当前字节
if (info_h.biBitCount == 8)
mask = 0;
else
mask >>= info_h.biBitCount;
rgb += 3;
shiftCnt++;
//如果到了行尾补充位则直接转到下一行
pixCnt++;
if (pixCnt == info_h.biWidth)
{
i = w - 1;
break;
}
//释放缓冲区等操作略过
...
得到了蒙版,就可以读取索引值了。使用条件运算符判断当前图像是不是8位的图像,如果是,则直接逐字节读取索引值index,while循环结束,继续下一字节的读取。
如果不是8位的图像,需要将当前字节data[i + w*j]与蒙版mask做与运算。与运算结束后,有效位还在高位,比如2位的索引值,这时是××000000,与需要的索引值××(000000××)相比需要向右移动6位,也就是8-biBitCount。读完之后一个字节还有后面其它像素的索引值,因此需要把mask向右移动相应的位数biBitCount,继续while循环,这时读到的索引值为00××0000,需要向右移动4位得到正确的索引值,也就是8-2*biBitCount。因此可以归纳出每次移位的位数是8-shiftCnt*biBitCount。
读取像素信息时行列分别进行循环,是为了方便判断是否达到行结尾处的补充位。pixCnt保存了当前一行里已经读了多少个像素的值,如果达到一行的像素数biWidth,则跳过后面的补充位,进到下一行。仍以图2中30×30像素4位的BMP图像为例,根据biSizeImage的计算公式算出一行实际字节数为16字节,有效位数为30*4=120bit,需要补充8位。实际图像数据从0x42开始,经过120bit(15字节)后到0x50,接下来的8位0x51就是补充的数据位,读取图像索引值时要跳过这样的位,把行循环i置为行结尾w-1,这样就可以进行新一行的循环。
另外在图2中可以看出,补充位不一定都是0,比如0xc1,0x101,0x111处都是补充位,但是这些字节里的某些位不是0。
(2)16位R5 G6 B5格式图像的读取
for (j = 0; j < h; j++)//j控制行循环
{
int pixCnt = 0;
for (i = 0; i < w; i+=2)//i控制列循环
{
*rgb = (data[i + w*j] & 0x1F) << 3;//B
*(rgb + 1) = ((data[i + w*j] & 0xE0) >> 3) + ((data[i + w*j + 1] & 0x07) << 5);//G低位+G高位
*(rgb + 2) = data[i + w*j + 1] & 0xF8; //R
rgb += 3;
//如果到了行尾补充位则直接转到下一行
pixCnt++;
if (pixCnt == info_h.biWidth)
i = w - 1;
}
}
以这种方式保存的图像RGB也不是整字节的值,仍需要用蒙版进行与运算然后移位。16位2个字节中RGB的保存顺序如下
- 将第一个字节data[i + w*j]与蒙版0x1F(0001 1111)与运算得到蓝色值,这时得到的值都在字节的低5位,范围是0~31,所以还要左移3位扩大到0~248,由于后3位都是0,因此B的值以8为单位递增。
- 将第二个字节data[i + w*j + 1]与蒙版0x07(0000 0111)与运算得到绿色的高3位G1,左移5位搬移到整个字节的高位;第一个字节与蒙版0xE0(1110 0000)与运算得到绿色的低3位G2,右移3位接到G1的后面。绿色值为0~252,后2位都是0,以4为单位递增。
- 将第二个字节与蒙版0xF8(1111 1000)与运算得到红色值,此时已经在高5位,范围是0~248,不需要移位,也以8为单位递增。
(3)24位R8 G8 B8格式图像的读取
for (j = 0; j < h; j++)//j控制行循环
{
int pixCnt = 0;
for (i = 0; i < w; i += 3)//i控制列循环
{
*rgb = data[i + w*j];//B
*(rgb + 1) = data[i + w*j + 1];//G
*(rgb + 2) = data[i + w*j + 2];//R
rgb += 3;
//如果到了行尾补充位则直接转到下一行
pixCnt++;
if (pixCnt == info_h.biWidth)
i = w - 1;
}
}
这种方式下RGB各占1字节,因此每次只要顺序读取即可。小端方式保存,保存顺序是B,G,R。读完之后前进3字节读取下一颜色值。
三、实验结果与总结
使用5张不同位深度的BMP图像进行测试,其中4位、8位、24位图各一张,16位图两张。分辨率均为800×540。
//--------main_yuvSequence.cpp--------
//命令行参数:输入图像数,输入图像文件名列表,输出文件名,图像宽度,图像高度
//包含头文件等略过
const int FRAME_NUM = 200;//总帧数
...
int imgCount = atoi(argv[1]);//图像总数
char** yuvFileName = (char**)malloc(imgCount);//图像名的数组
FILE** yuvFile = (FILE**)malloc(imgCount);//图像文件指针的数组
for (i = 0; i < imgCount; i++)
{
yuvFileName[i] = argv[i + 2];//依次获取各文件名
yuvFile[i] = fopen(yuvFileName[i], "rb");//依次打开各文件
if (yuvFile[i] == NULL)
...
}
...
int width = atoi(argv[imgCount + 3]);//图像宽度
int height = atoi(argv[imgCount + 4]);//图像高度
unsigned char* yBuf1 = (unsigned char*)malloc(width*height*1.5);//前一幅图像的缓冲区
unsigned char* yBuf2 = (unsigned char*)malloc(width*height*1.5);//后一幅图像的缓冲区
unsigned char* yBuf3 = (unsigned char*)malloc(width*height*1.5);//计算结果图像的缓冲区
...
输入图像为一个文件数组,数组大小由命令行参数控制,循环读入各个文件。实现此效果需要对YUV三个分量都进行处理,因此直接把YUV一起读入到缓冲区中,缓冲区大小为Y通道的1.5倍。
int delay = FRAME_NUM / (imgCount - 1);//过渡的持续时间
fread(yBuf1, 1, width*height*1.5, yuvFile[0]);//读入第一张图像
for (i = 0; i < (imgCount - 1); i++)//每张图像的循环
{
fread(yBuf2, 1, width*height*1.5, yuvFile[i+1]);//读入后一幅图像
for (j = 0; j < delay; j++)//每一段过渡的循环
{
double alpha = (double)j / (double)delay;//计算权值系数
for (k = 0; k < width*height*1.5; k++)//每一帧的循环
{
yBuf3[k] = (1 - alpha)*(yBuf1[k]) + alpha*(yBuf2[k]);//相邻两幅图按照权值相加,得到目标像素值
}
fwrite(yBuf3, 1, width*height*1.5, yuvSeqFile);//将计算后的叠加图像写入到文件
}
memccpy(yBuf1, yBuf2, 1, width*height*1.5);//把后一幅图像的数据复制到前一幅图像的缓冲区中,为下一张图像做准备
}
//释放缓冲区等略过
...
实现方法很简单,但是效率比较低,对每张图的像素值要单独计算得出结果,一次写入一帧画面。由于读取时的顺序为YUV,输出时也是先计算完Y分量的值写入文件,再计算U分量的值写入文件,等等。实验结果如表2所示,其中截取了整个序列中的某些帧