滤镜

要解决的问题
也许表示一张图片最简单的方式就是用像素(即点)组成的网格,每个像素可以是不同的颜色。对于黑白图片,我们只需要每个像素 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 分别表示”没有绿色”和”没有蓝色”。在本题中,你将操作单个像素的 R、G、B 值,最终创建你自己的图片滤镜。
在名为 filter-less 的文件夹中的 helpers.c 文件中,编写一个程序来给 BMP 图片应用滤镜。
演示
Distribution Code
对于这个问题,你将扩展由 CS50 工作人员提供的代码功能。
下载分发代码
登录 cs50.dev,点击你的终端窗口,然后执行 cd 本身。你应该会发现你的终端窗口的提示符类似于下面所示:
$
接下来执行
wget https://cdn.cs50.net/2026/x/psets/4/filter-less.zip
以便将一个名为 filter-less.zip 的 ZIP 文件下载到你的 codespace 中。
然后执行
unzip filter-less.zip
来创建一个名为 filter-less 的文件夹。你不再需要这个 ZIP 文件,因此你可以执行
rm filter-less.zip
在提示符处回复”y”然后按回车键,删除你刚下载的 ZIP 文件。
现在输入
cd filter-less
然后按回车键,进入(即打开)该目录。你的提示符现在应类似于以下内容。
filter-less/ $
单独执行 ls,你应该会看到几个文件:bmp.h、filter.c、helpers.h、helpers.c 和 Makefile。你还应该看到一个文件夹 images/,其中包含四个 BMP 文件。如果你遇到任何问题,请再次按照相同的步骤操作,看看是否能确定哪里出了错!
背景
位图更多技术细节
回忆一下,文件只是以某种方式排列的比特序列。因此,一个 24 位 BMP 文件本质上就是一个比特序列,其中(几乎)每 24 个比特恰好表示某个像素的颜色。但 BMP 文件也包含一些”元数据”,例如图片的高度和宽度等信息。该元数据存储在文件开头,形式为两个通常称为”headers”的数据结构,不要与 C 语言的头文件混淆。(顺便提一下,这些头部结构随着时间的推移不断演变。本题使用的是 Microsoft BMP 格式的最新版本 4.0,该版本随 Windows 95 首次亮相。)
这两个头部中的第一个称为 BITMAPFILEHEADER,长度为 14 字节。(回顾一下,1 字节等于 8 比特。)第二个头部称为 BITMAPINFOHEADER,长度为 40 字节。紧跟这些头部之后的是实际的位图:一个字节数组,每三个字节表示一个像素的颜色。然而,BMP 以相反顺序存储这些三元组(即 BGR),8 比特用于蓝色,然后是 8 比特用于绿色,最后是 8 比特用于红色。(有些 BMP 还将整个位图反向存储,即图片的顶行位于 BMP 文件的末尾。但我们按照此处描述的方式存储了本题的 BMP,即位图的第一行在前,最后一行在后。)换句话说,如果我们将上面的 1 位笑脸转换为 24 位笑脸,用红色替代黑色,那么一个 24 位 BMP 将按如下方式存储该位图,其中 0000ff 表示红色,ffffff 表示白色;我们已将所有 0000ff 的实例用红色高亮显示。

因为我们以从左到右、从上到下的顺序将这些比特排列在 8 列中,如果你往后退一步看,实际上可以看到那个红色的笑脸。
需要明确的是,回想一下,一个十六进制数字表示 4 比特。因此,ffffff 在十六进制中实际上表示二进制的 111111111111111111111111。
请注意,你可以将位图表示为二维像素数组:图片是一个行数组,每一行是一个像素数组。事实上,这正是我们在本题中选择表示位图图片的方式。
图片滤镜
对图片进行"滤镜"处理到底是什么意思?你可以将图片滤镜理解为提取某张原始图片的像素,然后以一种使得特定效果在结果图片中显现的方式修改每个像素。
规格说明
实现 helpers.c 中的函数,使得用户可以对其图片应用灰度、复古、反射或模糊滤镜。
- 函数
grayscale应接收一张图片并将其转换为同一图片的黑白版本。 - 函数
sepia应接收一张图片并将其转换为同一图片的复古版本。 - 函数
reflect应接收一张图片并将其水平反射。 - 最后,函数
blur应接收一张图片并将其转换为同一图片的盒式模糊版本。
你不应修改任何函数签名,也不应修改 helpers.c 以外的任何其他文件。
提示
理解分发代码
现在让我们来看一些作为分发代码提供给你的文件,以了解它们包含的内容。
bmp.h
打开 bmp.h(例如在文件浏览器中双击它)并查看。
你会看到我们提到的两个头部结构的定义(BITMAPINFOHEADER 和 BITMAPFILEHEADER)。此外,该文件还定义了 BYTE、DWORD、LONG 和 WORD,这些是 Windows 编程领域中常见的数据类型。请注意它们只是你已经(希望已经)熟悉的原始类型的别名。看起来 BITMAPFILEHEADER 和 BITMAPINFOHEADER 使用了这些类型。
也许对你来说最重要的是,该文件还定义了一个名为 RGBTRIPLE 的 struct,它简单地”封装”了三个字节:一个蓝色、一个绿色和一个红色(回想一下,这就是我们期望在磁盘上找到 RGB 三元组的顺序)。
为什么这些 struct 很有用?好的,回想一下,文件只是磁盘上的一系列字节(或最终是比特)。但这些字节通常以这样的方式排列:前几个表示某样东西,接下来的几个表示另一样东西,以此类推。”文件格式”之所以存在,是因为世界已经标准化了哪些字节表示什么含义。现在,我们可以将文件从磁盘读入 RAM,作为一个大的字节数组。我们可以记住 array[i] 处的字节表示一件东西,而 array[j] 处的字节表示另一件东西。但为什么不给其中一些字节取名字,这样我们就可以更容易地从内存中检索它们?这正是 bmp.h 中的结构体允许我们做到的事情。我们不必将某个文件视为一长串字节,而是可以将其视为一系列 struct。
filter.c
现在,让我们打开 filter.c。这个文件已经为你编写好了,但这里有几个值得注意的重点。
首先,注意第 10 行 filters 的定义。该字符串告诉程序允许的命令行参数:b、g、r 和 s。它们每个都指定了一种我们可以应用于图片的不同滤镜:模糊、灰度、反射和复古。
接下来的几行打开一个图片文件,确保它确实是 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:
$ make filter
然后,你可以通过运行以下命令来运行程序:
$ ./filter -g images/yard.bmp out.bmp
它将 images/yard.bmp 处的图片经过 grayscale 函数处理后,生成一个名为 out.bmp 的新图片。不过,grayscale 目前还没有任何功能,所以输出图片应该看起来和原来的 yard 一样。
实现 grayscale
一种常见的滤镜是”灰度”滤镜,即我们拿一张图片并希望将其转换为黑白。这是如何工作的?
- 回想一下,如果红色、绿色和蓝色的值都设置为
0x00(十六进制表示0),那么该像素就是黑色。如果所有值都设置为0xff(十六进制表示255),那么该像素就是白色。只要红色、绿色和蓝色的值都相等,结果将是黑白光谱上不同程度的灰色,较高的值表示较浅的色调(更接近白色),较低的值表示较深的色调(更接近黑色)。 - 因此,要将像素转换为灰度,你只需要确保红色、绿色和蓝色的值都相等。但你怎么知道该让它们等于什么值呢?合理的期望是,如果原来的红色、绿色和蓝色值都很高,那么新值也应该很高。如果原来的值都很低,那么新值也应该很低。
- 事实上,为了确保新图片的每个像素仍然具有与旧图片相同的大致亮度或暗度,你可以取红色、绿色和蓝色值的平均值来确定新像素的灰色调。
如果你对图片中的每个像素应用上述算法,结果将是一张转换为灰度的图片。编写一些伪代码来帮助你解决这个问题。
void grayscale(int height, int width, RGBTRIPLE image[height][width])
{
// Loop over all pixels
// Take average of red, green, and blue
// Update pixel values
}
首先,你如何遍历所有像素?回想一下,图片的像素存储在二维数组 image 中。要遍历二维数组,你需要两个循环,一个嵌套在另一个内部。
void grayscale(int height, int width, RGBTRIPLE image[height][width])
{
// Loop over all pixels
for (int i = 0; i < height; i++)
{
for (int j = 0; j < width; j++)
{
// Take average of red, green, and blue
// Update pixel values
}
}
}
现在,你可以使用 image[i][j] 来访问图片中的任意单个像素。但如何求红色、绿色和蓝色元素的平均值?回想一下,image 的每个元素都是一个 RGBTRIPLE,即 bmp.h 中定义的表示像素的 struct。访问 struct 成员的通常语法适用,其中 image[i][j].rgbtRed 将让你访问 RGBTRIPLE 的红色值,image[i][j].rgbtGreen 让你访问其绿色值,以此类推。
当你计算平均值时,记住像素的 rgbtRed、rgbtGreen 和 rgbtBlue 分量都是整数。因此,在将其赋值给像素值时,请务必对任何浮点数四舍五入到最接近的整数!另外,你为什么可能需要将这些整数的和除以 3.0 而不是 3?
一旦你将像素的红色、绿色和蓝色值取平均得到结果灰度颜色,就可以更新该像素的红色、绿色和蓝色值了。到目前为止,你已经熟悉了赋值语法!
实现 sepia
大多数图片编辑程序都支持”复古”滤镜,它通过使整个图片看起来略带红棕色,赋予图片一种老照片的感觉。
- 可以通过提取每个像素,并基于原始的三种色彩值计算新的红色、绿色和蓝色值,来将图片转换为复古风格。
- 有多种将图片转换为复古风格的算法,但对于本题,我们要求你使用以下算法。对于每个像素,应基于原始颜色值按如下公式计算复古颜色值。
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。因此,我们可以保证最终的红色、绿色和蓝色值将是 0 到 255(含)之间的整数。
编写一些伪代码来帮助你解决这个问题,并回想使用嵌套的 for 循环来访问每个像素。
void sepia(int height, int width, RGBTRIPLE image[height][width])
{
// Loop over all pixels
for (int i = 0; i < height; i++)
{
for (int j = 0; j < width; j++)
{
// Compute sepia values
// Update pixel with sepia values
}
}
}
要计算 sepia 值,请重新查看上面的要点。你有计算复古值的公式,但还有一些需要注意的地方。特别是,你需要……
- 将每次计算的结果四舍五入到最接近的整数
- 确保结果值不大于 255
在实现 sepia 时,一个返回两个整数中较小值的函数可能会派上用场,特别是当你需要确保颜色值不高于 255 时。欢迎你(但不是必需的)编写一个自己的辅助函数来实现这一点!
实现 reflect
有些滤镜可能还会移动像素的位置。例如,反射图片是一种滤镜,其结果图片就像你将原始图片放在镜子前得到的效果一样。
- 图片左侧的任何像素最终应出现在右侧,反之亦然。
- 请注意,原始图片的所有像素仍然会出现在反射后的图片中,只是这些像素可能已被重新排列,位于图片中的不同位置。
因此,在 reflect 函数中,你需要交换一行中相对两侧的像素值。编写一些伪代码来帮助你入门:
void reflect(int height, int width, RGBTRIPLE image[height][width])
{
// Loop over all pixels
for (int i = 0; i < height; i++)
{
for (int j = 0; j < width; j++)
{
// Swap pixels
}
}
}
回忆一下课堂上我们是如何使用临时变量实现交换两个值的。除非你想这样做,否则不需要使用单独的函数进行交换!
现在是一个思考你的嵌套 for 循环的好时机。外层 for 循环遍历每一行,而内层 for 循环遍历该行中的每个像素。然而,要成功反射一行,你需要遍历该行中的每个像素吗?
实现 blur
有多种方法可以创建模糊或柔化图片的效果。对于这个问题,我们将使用”盒式模糊”,其工作原理是取每个像素,并对其每种颜色值,通过对相邻像素的颜色值取平均来赋予新值。
-
考虑以下像素网格,我们已为每个像素编号。

- 每个像素的新值将是与原始像素在 1 行和 1 列范围内的所有像素(形成一个 3x3 的盒子)的值的平均值。例如,像素 6 的每种颜色值将通过取像素 1、2、3、5、6、7、9、10 和 11 的原始颜色值的平均值来获得(请注意,像素 6 本身也包含在平均值中)。同样,像素 11 的颜色值将通过取像素 6、7、8、10、11、12、14、15 和 16 的颜色值的平均值来获得。
- 对于边缘或角落的像素,如像素 15,我们仍然会寻找 1 行和 1 列范围内的所有像素:在这种情况下,像素 10、11、12、14、15 和 16。
在实现 blur 函数时,你可能会发现模糊一个像素最终会影响到另一个像素的模糊效果。最好的做法可能是通过声明一个新的二维数组来创建 image 的副本,代码如下:RGBTRIPLE copy[height][width];。然后使用嵌套的 for 循环逐个像素地将 image 复制到 copy 中,如下所示:
void blur(int height, int width, RGBTRIPLE image[height][width])
{
// Create a copy of image
RGBTRIPLE copy[height][width];
for (int i = 0; i < height; i++)
{
for (int j = 0; j < width; j++)
{
copy[i][j] = image[i][j];
}
}
}
现在,你可以从 copy 中读取像素的颜色,但在 image 中写入(即更改)像素的颜色!
讲解视频
请注意,此播放列表中有 5 个视频。在 YouTube 上打开。
如何测试
请务必在提供的示例 BMP 文件上测试你的所有滤镜!
正确性
check50 cs50/problems/2026/x/filter/less
代码风格
style50 helpers.c
如何提交
在终端中执行以下命令提交你的作业,并按提示完成操作。
submit50 cs50/problems/2026/x/filter/less