0x00 前言
在刷阿b的时候看到了不依赖三方库和框架,直接操纵像素在 CPU 上跑 Shader | Tsoding,有感而发。
0x01 PNM格式
pbm、pgm、ppm可以统称为pnm。是一套古老的图像存储格式,在电子邮件客户端还无法稳定加载图像的旧时代的遗物。
pbm - Portable Bitmap(便携式位图)
pgm - Portable Graymap (便携式灰度图)
ppm - Portable Pixmap (便携式像素图)
以及pam格式Portable Arbitrary Map(便携式任意映射),用来统一与拓展早期的pbm、pgm、ppm格式,支持多个通道。
其简单的格式很适合在特定情况下使用,比如资源受限的单片机。
或者使用C代码快速生成图像等。
可以使用netpbm、ffmpeg等工具处理这些格式的图像。
使用nomacs、XnView查看图片、使用hexdump查看二进制文件。
本blog主要参考netpbm官网的内容
0x02 格式详解
pbm pgm ppm每种格式都支持两种形式:ASCII字符形式表述 和 二进制(RAW)形式表述。其差别就只有ascii便于人识别和修改,二进制形式节省空间。
pbm pgm ppm三种格式都由文件头和数据组成。每种格式的ascii与raw表述形式在文件头部分格式也基本一致,只有数据部分不同。
文件头结构
[magic number]
[width] [height]
[maxval]
其中:
数据部分结构
ascii表示
- 当magic number为
P1、P2、P3时,数据部分应以可读的ascii十进制数字表述图片像素值,使用空格分隔。
- 起始于文件头下新的一行,且每一行不应超过70个字符。
- 对应像素从左到右,从上到下。
pbm图magic number = P1时的数据只应由字符0与1组成。
pgm图magic number = P2时的数据只应由字符0到maxval组成。
ppm图magic number = P3时的数据只应由字符0到maxval组成,三个数据为一组,顺序表示R-G-B即红-绿-蓝,每个通道的字符也由空格分隔。
raw二进制表示
- 当magic number为P4、P5、P6时,数据部分应以二进制存储。
- raw原始数据表述的数据以连续二进制字节存储,在文件头的下一行开始,不需要间隔或换行符分隔。
- 对应的像素从左到右,从上到下。
pbm图magic number = P4时的数据以字节(8bit)存储,且字节中的每一个bit表示一个像素,第一个字节的最高位为图像左上的像素。图像每一行像素数量无法被8整除时,最后一字节数据超出width的低位被忽略,仅有效的高位bit表示像素的数据。
pgm图magic number = P5时的数据由1-2字节对应一个灰度像素,当maxval小于256时1个字节对应1个灰度的像素,当maxval大于256小于65536时2个字节对应1个灰度的像素。
ppm图magic number = P6时的数据只由1-2字节对应RGB像素的一个色彩通道,当maxval小于256时1个字节对应1个一个色彩通道,当maxval大于256小于65536时2个字节对应1个色彩通道。三个通道组成一个像素,通道顺序固定为R-G-B。
0x03 PAM格式
pam实际上是第四种格式。可以在PAM图像中表示与PBM、PGM或PPM图像相同的信息。并且进一步的进行拓展。
PAM文件头
PAM的文件头也使用ASCII字符描述,对比与PNM更结构化,一个例子如下:
P7
WIDTH 24
HEIGHT 16
DEPTH 4
MAXVAL 255
TUPLTYPE RGB_ALPHA
ENDHDR
描述了一个带alpha透明通道的RBG图像的文件头,其中:
magic number:只有P7,且PAM只使用二进制表述数据。
WIDTH:其后跟随一个十进制数,表述图像宽度(行)。
HEIGHT:其后跟随一个十进制数,表述图像高度(列)。
DEPTH:其后跟随一个十进制数,表述图像深度,与TUPLTYPE配合使用。
MAXVAL:其后跟随一个十进制数,表述图像单个通道的最大值。
TUPLTYPE:其后是元组类型,文件头可以包含任意数量的TUPLTYPE行,包或0行即不包含TUPLTYPE。TUPLTYPE之后必须包含内容。
查找的标准来自netpbm官网,netpbm支持如下的TUPLTYPE:
BLACKANDWHITE:黑白位图,等同于PBM格式,DEPTH=1;MAXVAL=1 。值的注意的是PBM中白色为0,黑色为1,PAM-BLACKANDWHITE与之相反。
GRAYSCALE:灰度图,等同于PGM格式。DEPTH=1,其余设置参考PGM。
RGB:彩色图像,等同于PPM,DEPTH=3,其余设置参考PPM,通道顺序同样为R-G-B。
- 透明图像:在上述三种种类后加入
_ALPHA(BLACKANDWHITE_ALPHA、GRAYSCALE_ALPHA、RGB_ALPHA),在上述三种type的DEPTH+1表述透明通道。
- 一些其他的拓展格式…
ENDHDR:文件头的最后一行,其后的行为数据。
值的注意的是,一个PAM文件中可以包含多个图像。在上一幅图像数据的结尾后紧接下一幅图像的文件头。数据长度严格依照文件头中的定义。但是处理程序不一定支持多个图像解析,往往只处理第一副图像的数据。
WEIGHT、WIDTH、DEPTH、MAXVAL都至少为1 。
PAM数据
有且只有二进制表述,像素依然是从左到右、从上到下排列,字节的高bit在前。
与P5、P6一致,在MAXVAL小于256(1-255)时单个通道占用1字节宽度,在MAXVAL大于等于256小于65536(256-65535)时单个通道占用2字节宽度。
如果DEPTH=4且MAXVAL=255则连续4个字节表述一个像素的信息。
注意
一般图像查看应用不支持PAM格式,更多的作为自定义格式或中间格式。可以使用netpbm工具中的pamtopng指令将pam格式转换为png查看。然后再使用例如nomacs的图像预览工具查看png。
$ pamtopng test_raw.pam > test_raw.png
$ nomacs test_raw.png
后续找到XnView支持显示PAM格式,可以尝试使用。
0x04 代码片段
如上文提到,值的注意的是:
- 使用ascii表述时一行最好不要超过70个字符,需要作一些处理。
pbm格式1字节8bit表述8个像素,在行末不对齐的情景需要作位移处理。
c语言
主要使用fopen创建文件、fprintf写入ascii字符、fputc写入单个字节。
- magic number = P1,以ascii创建黑白位图:
int width = 124;
int height = 80;
FILE *f = fopen("test_ascii.pbm","w");
fprintf(f,"P1\n%d %d\n",width,height);
int n = 0;
int ch_step = 2;
for(int i=0;i<height;i++){
for(int j=0;j<width;j++){
fprintf(f,"%d ",(i+j)%2);
n+=ch_step;
if (n >= 70-ch_step){
fprintf(f,"\n");
n = 0;
}
}
}
fclose(f);
- magic number = P4,以二进制创建raw黑白位图:
int width = 24;
int height = 16;
FILE *f2 = fopen("test_raw.pbm","w");
fprintf(f,"P4\n%d %d\n",width,height);
for(int i=0;i<height;i++){
int n = 0;
char byte = 0;
for(int j=0;j<width;j++){
n++;
if (n%8 == 0) {
byte = byte << 1 | ((i+j)%2);
fputc(byte,f2);
n = 0;
byte = 0;
}else{
byte = byte << 1 | ((i+j)%2);
}
}
if (width%8 != 0) {
byte = byte << (8-width%8);
fputc(byte,f2);
}
}
fclose(f);
Python
主要使用open创建文件、file.write()写入、str.encode()将字符转换为字节。
形如下代码:
filename = 'test_raw.pgm'
magic_number = 'P5'
width = 16
height = 8
maxval = 15
def P5_raw_16x8_16g(x, y) -> int:
return (x + y) % 16
with open(filename, "wb") as pnm:
pnm.write(f"{magic_number}\n{width} {height}\n{maxval}\n".encode("ascii"))
dat_len = 256 if maxval < 256 else 65536
dat_bytes = 1 if maxval < 256 else 2
for y in range(height):
for x in range(width):
pnm.write((P5_raw_16x8_16g(x, y) % dat_len).to_bytes(dat_bytes))
sp:也可以使用numpy处理,使用numpy时如果超过1字节比如使用uint16存储maxval大于255的值,那么tobytes()返回的字节流默认是大端存储,要先用byteswap()转换一下大小端。
其他的格式懒得写了。
这网站还有人看吗orz