Please enable JavaScript to view the comments powered by Disqus.

BMP位图到ASCII字符画

最近闲得蛋疼,所以诞生了搞些大新闻的念头。

由于我不擅长(lǎn de xué)脚本语言,因此费力不讨好地去用C++来完成这项工作。

UPDATE 2016-03-18: 修复失效的图片链接

BMP简略介绍

位图一共有两种类型,即:设备相关位图(DDB)和设备无关位图(DIB). DDB位图在早期的Windows系统(Windows 3.0以前)中是很普遍的,事实上它也是唯一的。然而,随着显示器制造技术的进步,以及显示设备的多样化,DDB位图的一些固有的问题开始浮现出来了。比如,它不能够存储(或者说获取)创建这张图片的原始设备的分辨率,这样,应用程序就不能快速的判断客户机的显示设备是否适合显示这张图片。为了解决这一难题,微软创建了DIB位图格式。

设备无关位图(Device-Independent Bitmap)包含下列的颜色和尺寸信息:

  • 原始设备(即创建图片的设备)的颜色格式

  • 原始设备的分辨率

  • 原始设备的调色板

  • 一个位数组,由红、绿、蓝(RGB)三个值代表一个像素

  • 一个数组压缩标志,用于表明数据的压缩方案(如果需要的话)

BMP文件结构

Wikipedia: BMP文件结构图

MSDN: BMP文件结构简图

位图文件由以下结构体依次构成:

结构体名称可选大小用途备注
位图文件头14字节存储位图文件通用信息仅在读取文件时有用
DIB头固定(存在7种不同版本)存储位图详细信息及像素格式紧接在位图文件头后
附加位掩码3或4 DWORD(12或16字节)定义像素格式仅在DIB头是BITMAPINFOHEADER时存在
调色板见备注可变定义图像数据(像素数组)所用颜色色深≤ 8时不能省略
填充区A可变结构体对齐位图文件头中像素数组偏移量的产物
像素数组可变定义实际的像素数值像素数据在DIB头和附加位掩码中定义。像素数组中每行均以4字节对齐
填充区B可变结构体对齐DIB头中ICC色彩特性数据偏移量的产物
ICC色彩特性数据可变定义色彩特性可以包含外部文件路径,由该文件来定义色彩特性

BITMAPFILEHEADER

BITMAPFILEHEADER位于文件的开头,存储着整个文件的概要信息。MSDN上,该结构体的定义如下:

1
2
3
4
5
6
7
typedef struct tagBITMAPFILEHEADER {
WORD bfType;
DWORD bfSize;
WORD bfReserved1;
WORD bfReserved2;
DWORD bfOffBits;
} BITMAPFILEHEADER, *PBITMAPFILEHEADER;

注:WORD, DWORD为微软MFC的经典类型别名,类型的要求分别是16位无符号整型和32位无符号整型。

各变量作用如下:

  • bfType: 文件签名,标识文件类型。这两个字节内容是0x42 0x4D, 如果以char的方式来读取的话要求为'B', 'M'. 根据标准的不同,也可以是其他内容(见附表)。

  • bfSize: 文件大小,记录整个文件的大小(单位:字节)。

  • bfReserved1, bfReserved2: 保留位,其内容必须是0(MSDN); 取决于具体创建图形文件的程序(Wikipedia).

  • bfOffBits: 偏移量(单位:字节),标识从BITMAPFILEHEADER结构体的开头到位图像素数据的偏移量。

bfType的可能值

含义
BMWindows 3.1x, 95, NT, … etc.
BAOS/2 struct Bitmap Array
CIOS/2 struct Color Icon
CPOS/2 const Color Pointer
ICOS/2 struct Icon
PTOS/2 Pointer

BITMAPINFOHEADER

紧跟着BITMAPFILEHEADER的就是位图信息头,主要有BITMAPINFOHEADER, BITMAPV4HEADER, 以及BITMAPV5HEADER这几种。这里我们介绍使用最广的BITMAPINFOHEADER结构体。MSDN上定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct tagBITMAPINFOHEADER {
DWORD biSize;
LONG biWidth;
LONG biHeight;
WORD biPlanes;
WORD biBitCount;
DWORD biCompression;
DWORD biSizeImage;
LONG biXPelsPerMeter;
LONG biYPelsPerMeter;
DWORD biClrUsed;
DWORD biClrImportant;
} BITMAPINFOHEADER, *PBITMAPINFOHEADER;

注:LONG的要求为32位有符号整型。

各变量作用如下:

  • biSize: 该结构体需要的字节数。

  • biWidth: 位图的宽度(单位:像素)。 如果biCompressionBI_JPEGBI_PNG, 那么biWidth表示解压缩后图像的宽度。

  • biHeight: 位图的高度(单位:像素)。 如果为正,位图像素就是从下往上,从左往右存储,原点在左下角。如果为负,则是从上至下存储。此时,biCompression必须为BI_RGBBI_BITFIELDS. 从上至下存储的位图不能压缩。 如果biCompressionBI_JPEGBI_PNG, 那么biHeight表示解压缩后图像的高度。

  • biPlanes: 目标设备的颜色面板值数量。该值必须为1.

  • biBitCount: 每像素的位(bit)数。即图像色深。该成员变量决定了位图的最大色彩数。该值必须为表格内容之一(表格附后)。

  • biCompression: 表示从下至上存储的位图的压缩方式。具体值见下表(表格附后)。

  • biSizeImage: 图像的大小(单位:字节),无压缩位图该值可能设为0. 如果biCompressionBI_JPEGBI_PNG, 那么biSizeImage表示对应JPEG或PNG图像的缓冲区大小。

  • biXPelsPerMeter: 目标设备位图的横向分辨率(单位:像素/米)。程序可根据该值来选择合适大小的位图来显示。

  • biYPelsPerMeter: 目标设备位图的纵向分辨率(单位:像素/米)。

  • biClrUsed: 表示该位图中使用过的颜色索引数量。如果该值为0, 那么位图使用的是biCompression压缩模式下对应biBitCount的最大色彩数。 如果该值非0且biBitCount小于16, 那么biClrUsed表示图像引擎或设备驱动访问的色彩数。如果biBitCount大于等于16, 那么biClrUsed表示颜色表大小来优化系统调色板的性能。如果biBitCount等于16或32, 优化过的调色板紧跟着接下来的3个DWORD掩码后开始。 如果位图阵列紧跟在BITMAPINFO后,那么它是填充位图(packed bitmap). 填充位图是由单个指针引用。填充位图要求该biClrUsed必须是0或颜色表的实际大小。

  • biClrImportant: 为显示该位图所需要的颜色索引数量。如果该值为0, 那么所有颜色都是需要的。

biBitCount的可能值

含义
0色深由内含的JPEG或PNG格式图像显式确定或隐含
1位图是单色图。BITMAPINFObmiColors包含两个条目。位图的每一bit表示一个像素,如果是0则像素颜色是bmiColors的第一个条目内容;其余情况是第二个条目内容
4位图色彩数量最大为16, bmiColors包含16个条目。每四bit表示一个像素
8位图色彩数量最大为256, bmiColors包含256个条目。每一字节表示一个像素
16位图色彩数量最大为$2^{16}$. 如果biCompressionBI_RGB, 那么bmiColorsNULL. 每一WORD表示一个像素
24位图色彩数量最大为$2^{24}$, 并且bmiColorsNULL. 每3字节表示一个像素,每一字节分别表示一个像素的BGR分量
32位图色彩数量最大为$2^{32}$. 如果biCompressionBI_RGB, 那么bmiColorsNULL. 每一DWORD表示一个像素

biCompression的可能值

标识压缩方法备注
0BI_RGB最常见
1BI_RLE8RLE8位/像素只能用于格式为8位/像素的位图
2BI_RLE4RLE4位/像素只能用于格式为4位/像素的位图
3BI_BITFIELDS位字段或者霍夫曼1D压缩(BITMAPCOREHEADER2)像素格式由位掩码指定,或位图经过霍夫曼1D压缩(BITMAPCOREHEADER2)
4BI_JPEGJPEG或RLE-24压缩(BITMAPCOREHEADER2)位图包含JPEG图像或经过RLE-24压缩(BITMAPCOREHEADER2)
5BI_PNGPNG位图包含PNG图像
6BI_ALPHABITFIELDS位字段针对WindowsCE.NET4.0及之后版本

BITMAPINFO

该结构体包含了一个DIB的主要信息和色彩信息。MSDN定义如下:

1
2
3
4
typedef struct tagBITMAPINFO {
BITMAPINFOHEADER bmiHeader;
RGBQUAD bmiColors[1];
} BITMAPINFO, *PBITMAPINFO;

各变量作用如下:

  • bmiHeader: 见上一部分

  • bmiColors: 一个bmiColors包含以下二者之一:

    • 一个RGBQUAD的数组,数组元素组成了颜色表。
    • 一个16位无符号整型的数组,元素是本地调色板的颜色索引。

RGBQUAD

该结构体描述BGR色彩分量值。MSDN定义如下:

1
2
3
4
5
6
typedef struct tagRGBQUAD {
BYTE rgbBlue;
BYTE rgbGreen;
BYTE rgbRed;
BYTE rgbReserved;
} RGBQUAD;

注: BYTE的要求是宽为1字节的类型。一般为typedef unsigned char BYTE;

需要注意的是它的真实顺序是BGR而不是RGB.

读取BMP文件

有了上面的资料我们就能很快地写出读取文件的代码。

首先准备好两个文件头结构体,注意要禁止编译器自动对齐。在GCC/Clang下可以用__attribute__((packed)), VS下可以用#pragma pack(1).

bmp.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifndef __BMP__
#define __BMP__
#include <cstdint>

struct file_header{
uint8_t signature[2]; // 4 bytes signature, or you can use a single uint16_t instead.
uint32_t file_size;
uint16_t reserved1;
uint16_t reserved2;
uint32_t off_bits;
} __attribute__((packed));

struct info_header{
uint32_t info_header_size;
int32_t width;
int32_t height;
uint16_t planes;
uint16_t bit_count;
uint32_t compression;
uint32_t image_size;
int32_t x_res;
int32_t y_res;
uint32_t color_used;
uint32_t color_important;
} __attribute__((packed));

void read(const char *filename,file_header *f,info_header *i,uint8_t *data);

#endif

然后可以写一个简单的读取程序。除第一个参数表示文件路径外,其它的均是以指针形式返回的返回值。

我们直接为每个像素分配3字节的空间来存储RGB信息,而非使用RGBQUAD结构体。为简单起见,假设我们读取的BMP文件都是24位真彩色的,无压缩,从下至上存储。

bmp.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "bmp.h"
#include <fstream>
#include <stdexcept>

void read(const char *filename,file_header *f,info_header *i,uint8_t* &data,uint32_t &size_of_data){
std::fstream file(filename,std::ios::in|std::ios::binary);
if(!file.is_open()||!file.bad())
throw std::runtime_error("cannot open or read file");
file.read(reinterpret_cast<char*>(f),sizeof(file_header));
// check signature of file
if(f->signature[0]!='B'||f->signature[1]!='M'){
file.close();
throw std::runtime_error("Wrong image file type");
}
file.read(reinterpret_cast<char*>(i),sizeof(info_header));

if(i->bit_count<8){
file.close();
throw std::runtime_error("Unsupported format.");
}
if(i->compression > 1){
file.close();
throw std::runtime_error("Unsupported compression mode.");
}
// recalculate file size
file.seekg(0,std::ios::end);
f->file_size=file.tellg();

uint32_t data_size = i->width * i->height * i->bit_count / 8;

uint32_t image_size=f->file_size - data_size;
data=new uint8_t[image_size];
size_of_data=image_size;

// set to the beginning
file.seekg(f->off_bits,std::ios::beg);
// read pixel data
file.read(reinterpret_cast<char*>(data),image_size);
file.close();
}

灰度转换

现在我们得到了每个像素的RGB信息(上一步得到的data还需要经过处理变成RGB顺序),现在将其转换成灰度。我们使用这样的一个公式:

$$ Luminace = 0.299 Red + 0.587 Green + 0.114 Blue $$

最简单(bù dòng nǎo)的一个写法如下:
1
2
for(auto i=0U;i<size;i+=3)
output[i]=uint8_t(0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2] + 0.5);

最后加入+ 0.5是为了实现四舍五入而不是全部截断。

由于涉及到了浮点数,导致比整型之间的乘除法慢了很多。因此,我们把公式做个近似,变成了:

$$ Luminance = { {2 Red + 5 Green + Blue} \over 8} $$

在此基础上使用移位运算优化,整个变成了:

1
2
3
4
5
6
for(auto i=0U;i<size;i+=3){
auto temp=data[i] << 1; // 2*Red
temp+=(data[i+1]<<2) + data[i+1]; // 5*Green
temp+=data[i+2]; // Blue
output[i]=uint8_t(temp>>3); // divide by 8
}

转换到ASCII字符

参考资料中给出了两种思路,在此只简单介绍其中一种。

该方法的基本思路是,针对每一个点(一些像素组成的小区域),计算它平均灰度强度然后将其替换为强度差不多的字符。基于此我们需要一组字符,记为map. 其强度我们可以采取线性分布,即满足:

$$ \begin{aligned} intensity(map_i) = intensity(map_{i-1})+Constant \end{aligned} $$

然后选取字符时可以像查表一样:

$$ character=map_{intensity(dot) \over Constant} $$

因此,大致步骤如下:

  1. 将图片平均分割为像素点或者是(矩形)小区域

  2. 计算每个部分的灰度强度

  3. 将每个部分替换为与它灰度强度最相近的字符

我们所选取的字符组最好是强度均匀分布。刚上手时可以使用const char *map=" .,:;ox%#@";, 并按照强度递减排列处理。使用这个map的话,选取对应的字符的代码则是char c=map[(255-intensity(dot))*strlen(map)/256];.简略代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <string>
#include <cstring>
#include <cstdint>

std::string ascii(uint8_t *data,uint32_t height,uint32_t width,uint32_t line_width,const char *map="@#%xo;:,. "){
std::string result("");
uint32_t len=std::strlen(map);
for(auto y=0U;i<height;++y){
for(auto x=0U;x<width;++x){
auto intensity=data[y*line_width+x]+data[y*line_width+x+1]+data[y*line_width+x+2];
intensity=(intensity*len)/768; // for average
result+=map[len-intensity];
}
result+='\n';
}
return result;
}

转换的效果可以看图:

无耻的我从原作者处偷来的图

参考资料

  1. 彩色图到灰度图

  2. 灰度图到ASCII字符画

  3. Wikipedia: BMP文件格式

  4. MSDN: BMP文件格式

  5. Windows Data Types

作者:Dr. A. Clef
发布日期:2016-02-27
修改日期:2017-06-10
发布协议: BY-SA