滤镜 - CS50x 2023
实现一个程序,可以给 BMP 图像加滤镜,用法如下:
$ ./filter -r IMAGE.bmp REFLECTED.bmp
其中 IMAGE.bmp
是图像文件的名称,REFLECTED.bmp
是输出图像的文件名,这个图像是原图的镜像。
背景
位图
表示图像最简单的方法可能是使用像素(即点)网格,每个像素可以是不同的颜色。 对于黑白图像,我们因此需要每个像素 1 位,因为 0 可以表示黑色,1 可以表示白色,如下所示。
从这个意义上说,图像就是一个位图 (也就是位的集合)。 对于色彩更丰富的图像,每个像素需要更多的位来表示。 支持“24 位颜色”的文件格式(如 BMP、JPEG 或 PNG)每个像素使用 24 位。(BMP 实际上支持 1 位、4 位、8 位、16 位、24 位和 32 位颜色。)
24 位 BMP 使用 8 位来表示像素颜色中红色的量,8 位来表示像素颜色中绿色的量,8 位来表示像素颜色中蓝色的量。 如果听说过 RGB 颜色,那就是指:红色、绿色和蓝色。
如果 BMP 中某个像素的 R、G 和 B 值(例如)是十六进制的 0xff
、0x00
和 0x00
,则该像素是纯红色,因为 0xff
(也称为十进制的 255
)表示“大量的红色”,而 0x00
和 0x00
分别表示“没有绿色”和“没有蓝色”。
位图技术详解
回想一下,文件只是以某种方式排列的位序列。 那么,24 位 BMP 文件本质上只是一个位序列,(几乎)每 24 位恰好代表某个像素的颜色。 但是 BMP 文件还包含一些“元数据”,例如图像的高度和宽度等信息。 该元数据以两种数据结构的形式存储在文件的开头,通常称为“标头”,注意,这里的“标头”和 C 语言的头文件可不是一回事。(顺便提一下,这些标头是不断演变的。本题使用的是微软 BMP 格式的最新 4.0 版本,它首次出现在 Windows 95 中。)
第一个头部,名为BITMAPFILEHEADER
,长度为14个字节(1字节等于8位,请回忆一下)。紧随这些头部之后的是实际的位图数据,它是一个字节数组,每三个字节代表一个像素的颜色。然而,BMP以相反的顺序(即BGR)存储这些三元组:先是8位的蓝色,然后是8位的绿色,最后是8位的红色。(有些BMP也会以相反的顺序存储整个位图,例如将图像的顶行放在BMP文件的末尾。但本问题集提供的BMP文件,我们已按照前述方式存储,即顶行在前,底行在后。)换句话说,如果我们要将上面的1位笑脸转换为24位笑脸,用红色代替黑色,则24位BMP将按以下方式存储此位图,其中0000ff
表示红色,ffffff
表示白色;我们用红色突出显示了所有0000ff
的实例。
因为我们已经从左到右、从上到下地以8列的形式呈现了这些位,所以如果你退后一步,实际上可以看到红色的笑脸。
需要明确的是,一个十六进制数字代表4位。因此,十六进制数ffffff
实际上对应二进制数111111111111111111111111
。
请注意,您可以将位图表示为像素的二维数组:图像是行的数组,每行本身就是像素的数组。 实际上,这就是我们在本问题中选择的位图图像表示方法。
图像过滤
图像过滤究竟是什么意思?您可以将图像过滤理解为:获取原始图像的像素,并以某种方式修改它们,使结果图像呈现出特定的效果。
灰度
一种常见的滤镜是“灰度”滤镜,它可以将图像转换为黑白图像。 它是如何实现的呢?
回想一下,如果红色、绿色和蓝色值都设置为0x00
(十六进制表示0
),则像素为黑色。 如果所有值都设置为0xff
(十六进制表示255
),则像素为白色。 只要红色、绿色和蓝色值都相等,结果将是沿黑白光谱的不同深浅的灰色,值越高表示色调越浅(更接近白色),值越小表示色调越深(更接近黑色)。
因此,要将像素转换为灰度,我们只需要确保红色、绿色和蓝色值都相同即可。 但是我们如何知道要将它们设置为哪个值呢? 好吧,如果原始的红色、绿色和蓝色值都很大,那么新值也应该很大,这可能是合理的。 如果原始值都很小,那么新值也应该很小。
实际上,为了确保新图像的每个像素与旧图像保持大致相同的亮度,我们可以取红色、绿色和蓝色值的平均值,并将该平均值作为新像素的灰度值。
如果将此应用于图像中的每个像素,则结果将是转换为灰度的图像。
棕褐色
大多数图像编辑程序都支持“棕褐色”滤镜,它能使整个图像呈现红棕色调,营造出一种怀旧感。
将图像转换为棕褐色,需要获取每个像素,并根据其原始的红色、绿色和蓝色值计算新的颜色值。 将图像转换为棕褐色效果的算法有很多种,但对于此问题,我们将要求您使用以下算法。对于每个像素,棕褐色效果的颜色值应该按照下面的公式,根据原始颜色值计算得出。
sepiaRed = .393 * originalRed + .769 * originalGreen + .189 * originalBlue
sepiaGreen = .349 * originalRed + .686 * originalGreen + .168 * originalBlue
sepiaBlue = .272 * originalRed + .534 * originalGreen + .131 * originalBlue
当然,公式计算的结果有可能大于255;255是8位颜色值的上限值。如果计算结果大于255,红色、绿色和蓝色的值应该被限定在255。因此,可以保证最终的红色、绿色和蓝色数值都会是0到255之间的整数(包含0和255)。
镜像(反射)
有些滤镜也可能会移动像素。例如,镜像图像这种滤镜,会生成一张如同照镜子一般的图像。因此,图像左侧的像素会出现在右侧,反之亦然。
需要注意的是,镜像后的图像仍然包含原始图像的所有像素,只是它们的位置发生了变化。
模糊
有很多方法可以创建模糊或柔化图像的效果。在这个问题中,我们将使用“方框模糊”,它的原理是:对于每个像素,将其颜色值替换为周围相邻像素颜色值的平均值。
考虑以下像素网格,我们在其中对每个像素进行了编号。
每个像素的新值,将通过计算其周围一圈像素(总共3x3的方框)的平均值得出。例如,像素6的颜色值,是通过计算像素1、2、3、5、6、7、9、10和11的原始颜色值的平均值得到的(注意像素6本身也包含在内)。类似地,像素11的颜色值,则通过计算像素6、7、8、10、11、12、14、15和16的颜色值平均值得出。
对于边缘或角落的像素,比如像素15,我们仍然取其周围一圈的像素:像素10、11、12、14、15和16。
开始
登录 cs50.dev,单击您的终端窗口,然后单独执行 cd
。你应该会看到终端窗口的提示符类似下面这样:
接下来执行
wget https://cdn.cs50.net/2022/fall/psets/4/filter-less.zip
从而将名为 filter-less.zip
的ZIP文件下载到你的codespace。
然后执行
以创建一个名为 filter-less
的文件夹。您不再需要 ZIP 文件,因此您可以执行
并在提示符下回复“y”,然后按 Enter 键以删除您下载的 ZIP 文件。
现在输入
然后按回车键进入该目录。您的提示符现在应类似于以下内容。
执行ls
,你应该会看到一些文件:bmp.h
、filter.c
、helpers.h
、helpers.c
和Makefile
。你还会看到一个名为images
的文件夹,其中包含四个BMP文件。如果遇到问题,请重复上述步骤,检查哪里出错了!
理解
现在我们来了解一下提供的代码文件,看看它们都包含什么。
bmp.h
打开 bmp.h
文件(双击即可查看)。
你将看到前面提到的头文件 BITMAPINFOHEADER
和 BITMAPFILEHEADER
的定义。此外,该文件还定义了 BYTE
、DWORD
、LONG
和 WORD
等数据类型,这些类型常见于 Windows 编程。注意,它们只是你(应该)已经熟悉的原始数据类型的别名。BITMAPFILEHEADER
和 BITMAPINFOHEADER
结构体使用了这些类型。
更重要的是,该文件定义了一个名为 RGBTRIPLE
的结构体,它封装了三个字节:蓝色、绿色和红色分量(顺序与 RGB 三元组在磁盘上的存储顺序一致)。
这些结构体有什么用呢?回想一下,文件只是磁盘上的一系列字节(或最终是位)。但是,这些字节通常以某种方式排序,以便前几个字节代表某个事物,接下来的几个字节代表其他事物,依此类推。“文件格式”的存在是因为世界已经标准化了哪些字节表示什么。现在,我们可以将文件从磁盘读取到RAM中,作为一个大的字节数组。我们可以记住array[i]
处的字节代表一件事,而array[j]
处的字节代表另一件事。所以,为什么不给这些字节命名,方便我们从内存中读取呢?bmp.h
中的结构体就是为了方便我们这样做的。我们可以把文件看作一系列结构体,而不是一个长的字节序列。
filter.c
现在,让我们打开filter.c
。这个文件已经写好了,但有几个重点需要注意。
首先,注意第 10 行 filters
的定义。这个字符串指定了程序可以接受的命令行参数:b
、g
、r
和 s
。它们分别对应不同的图像滤镜:模糊 (blur)、灰度 (grayscale)、反射 (reflection) 和棕褐色 (sepia)。
接下来的代码会打开图像文件,确认是 BMP 格式,并将像素数据读取到名为 image
的二维数组中。
向下滚动到第 101 行开始的 switch
语句。注意,根据选择的 filter
参数,会调用不同的函数:如果选择 b
,则调用 blur
函数;选择 g
,则调用 grayscale
;选择 r
,则调用 reflect
;选择 s
,则调用 sepia
。这些函数都接受图像的高度、宽度以及像素数据的二维数组作为参数。
目标是修改像素数据的二维数组,从而实现所需的滤镜效果。
程序的剩余部分会将处理后的 image
写入新的图像文件。
helpers.h
接下来,看看 helpers.h
。这个文件很短,仅仅提供了前面提到的函数的函数原型。
在这里,请注意每个函数都接受一个名为 image
的二维数组作为参数,其中 image
是一个 height
行 width
列的 RGBTRIPLE
类型的二维数组。因此,如果 image
代表整个图片,那么 image[0]
代表第一行,image[0][0]
则代表图片左上角的像素。
helpers.c
现在,打开 helpers.c
。这里包含了 helpers.h
中声明的函数的具体实现。但请注意,由于目前实现是缺失的!这部分由你来完成。
Makefile
最后,让我们看看 Makefile
。这个文件指定了当我们运行像 make filter
这样的终端命令时应该发生什么。与之前你可能编写的单个文件的程序不同,filter
使用了多个文件:filter.c
和 helpers.c
。因此,我们需要告诉 make
如何编译这个文件。
尝试在终端中运行以下命令来编译 filter
:
然后,你可以通过运行以下命令来运行该程序:
$ ./filter -g images/yard.bmp out.bmp
该命令读取 images/yard.bmp
图像,并使用 grayscale
函数处理像素后,生成名为 out.bmp
的新图像。但由于 grayscale
函数尚未实现,输出图像应该和原始图像完全一样。
Specification
请在 helpers.c
中实现这些函数,使用户能够将灰度、棕褐色、反射或模糊滤镜应用到图像上。
- 函数
grayscale
应该接受一个图像,并将其转换为同一图像的黑白版本。 - 函数
sepia
应该接受一个图像,并将其转换为棕褐色风格的版本。 reflect
函数应该接受一个图像,并水平反射它。- 最后,
blur
函数应该接受一个图像,并将其转换为同一图像的盒状模糊版本。
除了 helpers.c
文件,你不应该修改任何其他文件以及函数签名。
Walkthrough
注意:此播放列表包含 5 个视频。
Usage
你的程序应该按照以下示例运行。INFILE.bmp
是输入图像的名称,OUTFILE.bmp
是应用滤镜后生成的图像的名称。
$ ./filter -g INFILE.bmp OUTFILE.bmp
$ ./filter -s INFILE.bmp OUTFILE.bmp
$ ./filter -r INFILE.bmp OUTFILE.bmp
$ ./filter -b INFILE.bmp OUTFILE.bmp
Hints
- 像素的
rgbtRed
、rgbtGreen
和rgbtBlue
分量都是整数,因此将浮点数赋值给像素时,务必四舍五入取整! - 在实现
grayscale
函数时,你需要对 3 个整数的值求平均值。为什么要把这些整数的和除以3.0
而不是3
呢? - 在
reflect
函数中,你需要交换一行中相对两侧像素的值。回想一下,我们在讲座中是如何用临时变量交换两个值的。除非你想用,否则没必要专门写一个交换函数! - 在实现
sepia
滤镜时,返回两个整数中较小值的函数有什么用呢?尤其是在需要确保颜色值不超过 255 的时候。 - 在实现
blur
函数时,你可能会发现模糊一个像素最终会影响另一个像素的模糊。可以考虑创建一个新的二维数组,例如RGBTRIPLE copy[height][width];
,然后用嵌套循环把image
(函数的第三个参数) 的内容逐像素复制到copy
中。 之后,从copy
读取像素颜色值,并将修改后的颜色值写入image
。