Chapter10 像素图
计算机图像分为2种:矢量图和像素图
- 矢量图保存了图像中每一几何物体的位置、形状、大小等信息,在显示图象时,根据这些信息计算得到完整的图象。
- 像素图将完整的图像分为若干行、若干列的小块(像素),保存每一个像素的颜色就可以得到整个图像。
这两种方法各有优缺点:
- 矢量图放大缩小时不会失真,但是如果图像复杂,则计算量很大。
- 像素图无论图像多么复杂,数据量和计算量都不增加,但是在放大所小时会失真。
10.1 BMP文件格式
这种文件格式可以保存单色位图、16色或256色索引模式像素图、24位真彩色图象,每种模式种单一像素的大小分别为1/8字节(1bit),1/2字节(4bit),1字节(8bit)和3字节(24bit)。
这种文件格式还定义了像素保存的几种方法,包括不压缩、RLE压缩等。常见的BMP文件大多是不压缩的。我们只讨论24位、不压缩的BMP。
Windows的BMP文件开头有54字节的文件头,保存了文件格式、颜色数、图像大小、压缩方式等信息,我们只需要注意图像大小这一项。
图象的宽度和高度都是一个32位整数,在文件中的地址分别为0x0012
和0x0016
,于是我们可以使用以下代码来读取图象的大小信息:
GLint width, height; // 使用OpenGL的GLint类型,它是32位的。
// 而C语言本身的int则不一定是32位的。
FILE* pFile;
// 在这里进行“打开文件”的操作
fseek(pFile, 0x0012, SEEK_SET); // 移动到0x0012位置
fread(&width, sizeof(width), 1, pFile); // 读取宽度
fseek(pFile, 0x0016, SEEK_SET); // 移动到0x0016位置
// 由于上一句执行后本就应该在0x0016位置
// 所以这一句可省略
fread(&height, sizeof(height), 1, pFile); // 读取高度
附
fseek
和fread
函数的使用:
fseek
函数原型:int fseek(FILE *stream, long offset, int fromwhere);
函数设置文件指针stream的位置。如果成功执行,则会将文件指针指向距离起始位置(
fromwhere
)偏移量(offset
)个字节的位置。
fread
函数原型:size_t fread( void *buffer, size_t size, size_t count, FILE *stream );
从给定输入流
stream
读取最多count
个大小为size
的对象到数组buffer
中
注意两点:
- OpenGL通常使用RGB来表示颜色,但BMP文件则采用BGR,顺序是不同的。
- 像素的数据量并不一定完全等于图象的高度乘以宽度乘以每一像素的字节数,而是可能略大于这个值。原因是BMP文件采用了一种对齐机制,每一行像素数据的长度若不是4的倍数,则填充一些数据让他是4的倍数。
比如,如果一幅BMP是个像素的24位BMP,则每一行是,要求每一行是4的倍数个字节,因此变为52字节,于是总的大小变成。
由于第二点,可以使用如下程序来计算数据长度:
int LineLength, TotalLength;
LineLength = ImageWidth * BytesPerPixel; // 每行数据长度大致为图象宽度乘以
// 每像素的字节数
while( LineLength % 4 != 0 ) // 修正LineLength使其为4的倍数
++LineLenth;
TotalLength = LineLength * ImageHeight; // 数据总长 = 每行长度 * 图象高度
10.2 OpenGL像素操作
提供了如下3个函数来操作像素:
glReadPixels
:读取一些像素(从缓冲区)。当前可以简单理解为“把已经绘制好的像素(它可能已经被保存到显卡的显存中)读取到内存”。glDrawPixels
:绘制一些像素。当前可以简单理解为“把内存中一些数据作为像素数据,进行绘制”。glCopyPixels
:复制一些像素。当前可以简单理解为“把已经绘制好的像素从一个位置复制到另一个位置”。看上去等价于先读取像素再进行绘制,但实际上不需要将已经画好的像素转化为内存数据,然后再重新绘制,比先读取后绘制快。
10.2.1 glReadPixels
函数原型:void glReadPixels (GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, GLvoid *pixels);
通过前4个参数就可以得到一个矩形框:
(x,y)表示矩形的左下角的坐标,坐标以窗口最左下角为零,最右上角为最大值,width和height表示了其宽度和高度。所以,如果绘制窗口的宽度为WindowWidth和WindowHeight,则可以将这4个参数设置为0, 0, WindowWidth, WindowHeight
,这样就能把绘制窗口的像素都读到内存。
第5个参数format
表示读取的内容,例如:GL_RGB
就会依次读取像素的红、绿、蓝三种数据,GL_RGBA
则会依次读取像素的红、绿、蓝、alpha四种数据,GL_RED则只读取像素的红色数据(类似的还有GL_GREEN,GL_BLUE,以及GL_ALPHA)。
第6个参数type
表示读取的内容保存到内存时所使用的类型,如GL_UNSIGNED_BYTE
会把各种数据保存为GLubyte,GL_FLOAT
会把各种数据保存为GLfloat等
第7个参数表示一个指针,像素数据被读取后,将被保存到这个指针所表示的地址。
解决RGB和BMP的BGR格式不一致问题:
- 新版本的OpenGL支持
GL_BGR
- 如果1没有,则可以使用
GL_BGR_EXT
消除BMP文件中“对齐”带来的影响
实际上OpenGL也支持使用了这种“对齐”方式的像素数据。只要通过glPixelStore修改“像素保存时对齐的方式”就可以了。像这样:
int alignment = 4;
glPixelStorei(GL_UNPACK_ALIGNMENT, alignment);
第一个参数表示“设置像素的对齐值”,第二个参数表示实际设置为多少。
例子:
假设我们先随便建立一个24位色BMP,文件名为dummy.bmp(只是复制它的文件头给新文件用),又假设新的BMP文件名称为grab.bmp。则可以编写如下代码来将任意一个程序运行的结果保存为bmp文件(下面代码用的是混合一章中的二维混合示例代码,将运行结果保存为bmp):
#define WindowWidth 400
#define WindowHeight 400
#include <GL/glut.h>
#include <stdio.h>
#include <stdlib.h>
/* 函数grab
* 抓取窗口中的像素
* 假设窗口宽度为WindowWidth,高度为WindowHeight
*/
#define BMP_Header_Length 54
void grab(void)
{
FILE* pDummyFile;
FILE* pWritingFile;
GLubyte* pPixelData;
GLubyte BMP_Header[BMP_Header_Length];
GLint i, j;
GLint PixelDataLength;
// 计算像素数据的实际长度
i = WindowWidth * 3; // 得到每一行的像素数据长度
while (i % 4 != 0) // 补充数据,直到i是的倍数
++i; // 本来还有更快的算法,
// 但这里仅追求直观,对速度没有太高要求
PixelDataLength = i * WindowHeight;
// 分配内存和打开文件
pPixelData = (GLubyte*)malloc(PixelDataLength);
if (pPixelData == 0)
exit(0);
pDummyFile = fopen("dummy.bmp", "rb"); // 注意修改这里的路径!!!,dummy只是给一个文件头的示例,所以可以自己创建一个bmp文件,但是需要画一点东西,因为不画的话大小为0字节,实际上不包含头部
if (pDummyFile == 0)
exit(0);
pWritingFile = fopen("grab.bmp", "wb"); // 如果不存在会创建
if (pWritingFile == 0)
exit(0);
// 读取像素
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glReadPixels(0, 0, WindowWidth, WindowHeight,
GL_BGR_EXT, GL_UNSIGNED_BYTE, pPixelData);
// 把dummy.bmp的文件头复制为新文件的文件头
fread(BMP_Header, sizeof(BMP_Header), 1, pDummyFile);
fwrite(BMP_Header, sizeof(BMP_Header), 1, pWritingFile);
fseek(pWritingFile, 0x0012, SEEK_SET);
i = WindowWidth;
j = WindowHeight;
fwrite(&i, sizeof(i), 1, pWritingFile);
fwrite(&j, sizeof(j), 1, pWritingFile);
// 写入像素数据
fseek(pWritingFile, 0, SEEK_END);
fwrite(pPixelData, PixelDataLength, 1, pWritingFile);
// 释放内存和关闭文件
fclose(pDummyFile);
fclose(pWritingFile);
free(pPixelData);
}
void myDisplay(void)
{
glClear(GL_COLOR_BUFFER_BIT);
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE);
glColor4f(1, 0, 0, 0.5); // 红色是目标
glRectf(-1, -1, 0.5, 0.5);
glColor4f(0, 1, 0, 0.5); // 绿色是源
glRectf(-0.5, -0.5, 1, 1);
grab();
glutSwapBuffers();
}
int main(int argc, char* argv[])
{
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_RGBA | GLUT_DOUBLE);
glutInitWindowPosition(200, 200);
glutInitWindowSize(WindowWidth, WindowHeight);
glutCreateWindow("OpenGL 窗口");
glutDisplayFunc(&myDisplay);
glutMainLoop();
return 0;
}
这里需要注意几点:
1、教程中的有个地方说的并不对,如果开启了双缓冲的话,默认应该是从后缓冲读,而非前缓冲,见window的opengl文档https://docs.microsoft.com/zh-cn/windows/win32/opengl/glreadbuffer。
总之,如果不确定的话,在readpixels之前使用readbuffer指定从哪个缓冲区读,就不会出错了。
2、dummy.bmp放在文件夹中和c文件一级的文件夹下(项目文件夹下),grab.bmp不需要自己创建一个,因为不存在的话为自己生成一个,同样
10.2.2 glDrawPixels
函数原型:void APIENTRY glDrawPixels (GLsizei width, GLsizei height, GLenum format, GLenum type, const GLvoid *pixels);
对比一下glReadPixels :void glReadPixels (GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, GLvoid *pixels);
glDrawPixels
函数与glReadPixels函数相比,参数内容大致相同。它的第一、二、三、四个参数分别对应于glReadPixels
函数的第三、四、五、六个参数,依次表示图象宽度、图象高度、像素数据内容、像素数据在内存中的格式。最后一个表示绘制的像素数据在内存中的位置。
区别主要在于少了前两个参数,不必显式的指定绘制的位置,因为绘制的位置是由另一个函数glRasterPos*
来指定的。
如果只是想要将bmp100%画出来,不需要用
glRasterPos*
指定,只用glDrawPixels
即可。
下面代码展示了读入10.2.1节中的grab.bmp,把它画在屏幕上。
#include <gl/glut.h>
#include <stdio.h>
#include <stdlib.h>
static GLint ImageWidth;
static GLint ImageHeight;
static GLint PixelLength;
static GLubyte* PixelData;
void display(void)
{
// 清除屏幕并不必要
// 每次绘制时,画面都覆盖整个屏幕
// 因此无论是否清除屏幕,结果都一样
// glClear(GL_COLOR_BUFFER_BIT);
// 绘制像素
glDrawPixels(ImageWidth, ImageHeight,
GL_BGR_EXT, GL_UNSIGNED_BYTE, PixelData);
// 完成绘制
glutSwapBuffers();
}
int main(int argc, char* argv[])
{
// 打开文件
FILE* pFile = fopen("grab.bmp", "rb");
if (pFile == 0)
exit(0);
// 读取图象的大小信息
fseek(pFile, 0x0012, SEEK_SET);
fread(&ImageWidth, sizeof(ImageWidth), 1, pFile);
fread(&ImageHeight, sizeof(ImageHeight), 1, pFile);
// 计算像素数据长度
PixelLength = ImageWidth * 3;
while (PixelLength % 4 != 0)
++PixelLength;
PixelLength *= ImageHeight;
// 读取像素数据
PixelData = (GLubyte*)malloc(PixelLength);
if (PixelData == 0)
exit(0);
fseek(pFile, 54, SEEK_SET);
fread(PixelData, PixelLength, 1, pFile);
// 关闭文件
fclose(pFile);
// 初始化GLUT并运行
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA);
glutInitWindowPosition(100, 100);
glutInitWindowSize(ImageWidth, ImageHeight);
glutCreateWindow("draw pixels");
glutDisplayFunc(&display);
glutMainLoop();
// 释放内存
// 实际上,glutMainLoop函数永远不会返回,这里也永远不会到达
// 这里写释放内存只是出于一种个人习惯
// 不用担心内存无法释放。在程序结束时操作系统会自动回收所有内存
free(PixelData);
return 0;
}
10.2.3 glCopyPixels
从效果上看,
glCopyPixels
进行像素复制的操作,等价于把像素读取到内存,再从内存绘制到另一个区域,因此可以通过glReadPixels
和glDrawPixels
组合来实现复制像素的功能。但如果用这种组合,对CPU的负担就太大了,而glCopyPixels
直接从像素数据复制出新的像素数据,避免了多余的数据的格式转换,并且也可能减少一些数据复制操作(因为数据可能直接由显卡负责复制,不需要经过主内存),因此效率比较高。
函数原型void glCopyPixels (GLint x, GLint y, GLsizei width, GLsizei height, GLenum type);
前四个参数和glReadPixels
相同,第一、二个参数表示复制像素来源的矩形的左下角坐标,第三、四个参数表示复制像素来源的举行的宽度和高度
第五个参数通常使用GL_COLOR
,表示复制像素的颜色,但也可以是GL_DEPTH
或GL_STENCIL
,分别表示复制深度缓冲数据或模板缓冲数据。
在看例子之前,补充2个对像素进行处理的常用函数:
glPixelZoom
最常用的处理可能就是对整个像素图象进行放大/缩小。使用glPixelZoom
来设置放大/缩小的系数,该函数有两个参数,分别是水平方向系数和垂直方向系数。例如设置glPixelZoom(0.5f, 0.8f);
则表示水平方向变为原来的50%大小,而垂直方向变为原来的80%大小。
glRasterPos2i
这个之前说到过,这里给出具体的使用方法。函数原型:void glRasterPos2i( GLint x, GLint y );
例子
绘制一个三角形后,复制像素,并同时进行水平和垂直方向的翻转,然后缩小为原来的一半,并绘制。绘制完毕后,调用前面的grab函数,将屏幕中所有内容保存为grab.bmp。其中WindowWidth和WindowHeight是表示窗口宽度和高度的常量。
void display(void)
{
// 清除屏幕
glClear(GL_COLOR_BUFFER_BIT);
// 绘制
glBegin(GL_TRIANGLES);
glColor3f(1.0f, 0.0f, 0.0f); glVertex2f(0.0f, 0.0f);
glColor3f(0.0f, 1.0f, 0.0f); glVertex2f(1.0f, 0.0f);
glColor3f(0.0f, 0.0f, 1.0f); glVertex2f(0.5f, 1.0f);
glEnd();
glPixelZoom(-0.5f, -0.5f);
glRasterPos2i(1, 1);
glCopyPixels(WindowWidth/2, WindowHeight/2,
WindowWidth/2, WindowHeight/2, GL_COLOR);
// 完成绘制,并抓取图象保存为BMP文件
glutSwapBuffers();
grab();
}
如果只是画三角形,效果如图:
而上面的操作相当于是将右上脚的矩形(对应WindowWidth/2, WindowHeight/2, WindowWidth/2, WindowHeight/2
)在水平和竖直方向都缩小一半,然后再翻转(因为glPixelZoom(-0.5f, -0.5f);
中2个都是-0.5),最后放到(1,1)的位置处(对应glRasterPos2i(1, 1);
,即屏幕的右上角)
得到的结果如下: