过滤器 - CS50x 2026

Harvard Yard with edge detection

待解决的问题

表示图像最简单的方法或许就是使用像素(即点)网格,每个像素都可以有不同的颜色。对于黑白图像,我们每个像素只需要 1 位,0 可以表示黑色,1 可以表示白色,如下图所示。

a simple bitmap

从这个意义上说,图像就是一个位图(即位的映射)。对于色彩更丰富的图像,你只需要为每个像素提供更多位即可。支持 “24 位彩色” 的文件格式(如 BMPJPEGPNG)每个像素使用 24 位。(BMP 实际上支持 1、4、8、16、24 和 32 位彩色。)

24 位 BMP 使用 8 位来表示像素颜色中的红色含量,8 位表示绿色含量,8 位表示蓝色含量。如果你听说过 RGB 颜色,这就是它的来源:红(red)、绿(green)、蓝(blue)。

如果 BMP 中某个像素的 R、G、B 值在十六进制中分别是 0xff0x000x00,那么该像素就是纯红色的,因为 0xff(也就是十进制中的 255)表示 “很多红色”,而 0x000x00 分别表示 “没有绿色” 和 “没有蓝色”。在本题中,你将通过操作单个像素的这些 R、G、B 值,最终创建属于你自己的图像过滤器。

在一个名为 filter-more 的文件夹下的 helpers.c 文件中,编写一个程序对 BMP 应用过滤器。

演示

发行代码

对于这个问题,你将扩展 CS50 工作人员提供给你的代码的功能。

下载发行代码

登录 cs50.dev,点击你的终端窗口,然后单独执行 cd。你应该会发现终端窗口的提示符类似于:

$

接下来执行

wget https://cdn.cs50.net/2026/x/psets/4/filter-more.zip

以便将名为 filter-more.zip 的压缩包下载到你的 codespace。

然后执行

unzip filter-more.zip

创建一个名为 filter-more 的文件夹。你不再需要该 ZIP 文件,因此可以执行

rm filter-more.zip

并在提示符处输入 “y” 紧接着按回车键,以删除你下载的 ZIP 文件。

现在输入

cd filter-more

接着按回车键进入(即打开)该目录。你的提示符现在应该类似于:

filter-more/ $

单独执行 ls,你应该会看到几个文件:bmp.hfilter.chelpers.hhelpers.cMakefile。你还应该看到一个名为 images 的文件夹,里面有四个 BMP 文件。如果你遇到任何困难,请再次按照这些步骤操作,看看能否确定哪里出错了!

背景

关于位图的更多技术细节

回想一下,文件只是以某种方式排列的位序列。那么,一个 24 位 BMP 文件本质上只是一个位序列,其中(几乎)每 24 位恰好代表某个像素的颜色。但 BMP 文件还包含一些 “元数据(metadata)”,即图像的高度和宽度等信息。这些元数据以两个数据结构的形式存储在文件的开头,通常被称为 “文件头(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

red smile

因为我们是按照从左到右、从上到下、分 8 列呈现这些位的,所以如果你退后一步,实际上可以看到红色的笑脸。

需要明确的是,回想一下一个十六进制数字代表 4 位。因此,十六进制的 ffffff 实际上表示二进制中的 111111111111111111111111

注意,你可以将位图表示为像素的二维数组:图像是一个行数组,每一行是一个像素数组。事实上,这就是我们在本题中选择表示位图图像的方式。

图像过滤

过滤图像到底是什么意思?你可以将过滤图像看作是获取原始图像的像素,并以某种方式修改每个像素,使得结果图像中呈现出特定的效果。

灰度 (Grayscale)

一种常见的过滤器是 “灰度” 过滤器,我们将图像转换为黑白。这是如何工作的呢?

回想一下,如果红色、绿色和蓝色值都设置为 0x00(十六进制的 0),则像素为黑色。如果所有值都设置为 0xff(十六进制的 255),则像素为白色。只要红、绿、蓝值都相等,结果将是黑白光谱中不同深浅的灰色,值越高意味着色调越浅(越接近白色),值越低意味着色调越深(越接近黑色)。

因此,要将像素转换为灰度,我们只需要确保红色、绿色和蓝色值都是相同的值。但是我们怎么知道将它们设为什么值呢?嗯,如果原始的红、绿、蓝值都相当高,那么新值也应该相当高,这大概是合理的预期。如果原始值都很低,那么新值也应该很低。

事实上,为了确保新图像的每个像素仍然具有与旧图像相同的大致亮度或暗度,我们可以取红色、绿色和蓝色值的平均值,来确定将新像素设为什么深浅的灰色。

如果你对图像中的每个像素都应用这个处理,结果将是一张转换为灰度的图像。

镜像 (Reflection)

某些过滤器也可能会移动像素。例如,镜像图像是一种过滤器,其结果图像就像是你把原始图像放在镜子前得到的一样。因此,图像左侧的任何像素都应该最终出现在右侧,反之亦然。

请注意,原始图像的所有原始像素仍将存在于镜像图像中,只是这些像素可能已重新排列到图像中的不同位置。

模糊 (Blur)

有很多方法可以产生模糊或柔化图像的效果。对于本题,我们将使用 “方框模糊(box blur)”,它的工作原理是获取每个像素,并对于每个颜色值,通过取相邻像素颜色值的平均值来赋予它一个新值。

考虑下面的像素网格,我们为每个像素编了号。

a grid of pixels

每个像素的新值将是该像素周围 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。

边缘检测 (Edges)

在用于图像处理的人工智能算法中,检测图像中的边缘(图像中创建物体之间边界的线条)通常很有用。实现这种效果的一种方法是对图像应用 Sobel 算子

与图像模糊一样,边缘检测也是通过获取每个像素,并根据该像素周围的 3x3 像素网格进行修改来工作的。但 Sobel 算子不是只取九个像素的平均值,而是通过计算周围像素值的加权和来计算每个像素的新值。由于物体之间的边缘可能在垂直和水平两个方向上产生,你实际上需要计算两个加权和:一个用于检测 x 方向的边缘,一个用于检测 y 方向的边缘。具体来说,你将使用以下两个 “算子模板(kernels)”:

Sobel kernels

如何解释这些模板?简而言之,对于每个像素的三个颜色值中的每一个,我们将计算两个值 GxGy。例如,要计算像素红色通道值的 Gx,我们将取该像素周围 3x3 方框内九个像素的原始红色值,分别乘以 Gx 模板中对应的值,然后取结果值的总和。

为什么模板使用这些特定的值?以 Gx 方向为例,我们将目标像素右侧的像素乘以正数,将目标像素左侧的像素乘以负数。当我们求和时,如果右侧的像素与左侧的像素颜色相近,结果将接近 0(数字抵消)。但如果右侧的像素与左侧的像素颜色差异很大,那么得到的值将是非常大的正数或负数,表明颜色发生了变化,这很可能是物体之间边界的结果。类似的论据也适用于计算 y 方向的边缘。

使用这些模板,我们可以为像素的红色、绿色和蓝色通道中的每一个生成一个 GxGy 值。但每个通道只能取一个值,而不是两个:所以我们需要某种方法将 GxGy 组合成一个值。Sobel 过滤器算法通过计算 Gx^2 + Gy^2 的平方根将 GxGy 组合成最终值。由于通道值只能取 0 到 255 之间的整数值,请确保将结果值四舍五入到最近的整数,并限制在 255 以内!

那么如何处理图像边缘或角落的像素呢?处理边缘像素的方法有很多,但就本题而言,我们要求你将图像视为在图像边缘周围有一个 1 像素宽的纯黑色边框:因此,尝试访问图像边缘之外的像素应被视为纯黑色像素(红、绿、蓝的值均为 0)。这将有效地在我们的 GxGy 计算中忽略这些像素。

规格说明

helpers.c 中实现相关函数,以便用户可以对图像应用灰度、镜像、模糊或边缘检测过滤器。

  • 函数 grayscale 应该获取一张图像,并将其转换为同一图像的黑白版本。
  • reflect 函数应该获取一张图像,并水平翻转。
  • blur 函数应该获取一张图像,并将其转换为同一图像的方框模糊版本。
  • edges 函数应该获取一张图像,并根据 Sobel 算子突出显示物体之间的边缘。

你不应该修改任何函数签名,也不应该修改除 helpers.c 以外的任何其他文件。

理解代码

现在让我们看看作为发行代码提供给你的一些文件,以了解其中的内容。

bmp.h

打开 bmp.h(通过在文件浏览器中双击它)看看。

你会看到我们提到过的文件头的定义(BITMAPINFOHEADERBITMAPFILEHEADER)。此外,该文件还定义了 BYTEDWORDLONGWORD,这些是 Windows 编程世界中常见的数据类型。请注意它们只是你(希望)已经熟悉的原始类型的别名。看起来 BITMAPFILEHEADERBITMAPINFOHEADER 使用了这些类型。

对你来说最重要的可能是,该文件还定义了一个名为 RGBTRIPLEstruct(结构体),它非常简单地 “封装” 了三个字节:一个蓝色、一个绿色和一个红色(请回想一下,这是我们期望在磁盘上实际找到 RGB 三元组的顺序)。

为什么这些 struct 有用?请回想一下,文件只是磁盘上的字节(或最终是位)序列。但这些字节通常是以某种方式排序的,前几个代表一件事,接下来的几个代表另一件事,依此类推。“文件格式” 之所以存在,是因为世界已经标准化了哪些字节代表什么。现在,我们可以直接将磁盘中的文件作为一大组字节读取到 RAM 中。我们可以记住 array[i] 处的字节代表一件事,而 array[j] 处的字节代表另一件事。但为什么不给其中一些字节命名,以便我们可以更轻松地从内存中检索它们呢?这正是 bmp.h 中的结构体允许我们做的事情。与其将某个文件视为一个长长的字节序列,我们可以将其视为一个 struct 序列。

filter.c

现在,让我们打开 filter.c。这个文件已经为你写好了,但这里有几个重要的点值得注意。

首先,注意第 10 行 filters 的定义。该字符串告诉程序该程序允许的命令行参数是什么:begr。它们每一个都指定了我们可能应用于图像的不同过滤器:模糊(blur)、边缘检测(edge detection)、灰度(grayscale)和镜像(reflection)。

接下来的几行打开一个图像文件,确保它确实是一个 BMP 文件,并将所有像素信息读取到一个名为 image 的二维数组中。

向下滚动到从第 101 行开始的 switch 语句。请注意,根据我们选择的 filter,会调用不同的函数:如果用户选择过滤器 b,程序调用 blur 函数;如果是 e,则调用 edges;如果是 g,则调用 grayscale;如果是 r,则调用 reflect。还要注意,这些函数中的每一个都将图像的高度、图像的宽度和像素的二维数组作为参数。

这些是你(很快!)要实现的函数。正如你所料,目标是让这些函数中的每一个都以某种方式编辑像素的二维数组,从而将所需的过滤器应用于图像。

程序的其余行获取结果 image 并将它们写出一个新的图像文件。

helpers.h

接下来,看看 helpers.h。这个文件很短,只提供了你之前看到的函数的原型。

在这里,请注意每个函数都接受一个名为 image 的二维数组作为参数,其中 image 是一个有 height 行的数组,每行本身又是另一个有 widthRGBTRIPLE 的数组。因此,如果 image 代表整张图片,那么 image[0] 代表第一行,image[0][0] 代表图像左上角的像素。

helpers.c

现在,打开 helpers.c。这里是 helpers.h 中声明的函数的实现所属的地方。但请注意,目前这些实现是缺失的!这部分由你决定。

Makefile

最后,让我们看看 Makefile。这个文件指定了当我们运行类似 make filter 的终端命令时应该发生什么。虽然你之前编写的程序可能仅限于一个文件,但 filter 似乎使用了多个文件:filter.chelpers.c。因此我们需要告诉 make 如何编译这个文件。

试着自己编译 filter,去你的终端并运行:

$ make filter

然后,你可以通过运行以下命令来运行该程序:

$ ./filter -g images/yard.bmp out.bmp

它获取 images/yard.bmp 处的图像,并在通过 grayscale 函数运行像素后生成一个名为 out.bmp 的新图像。不过,grayscale 现在还没做任何事情,所以输出图像看起来应该和原始的 yard 图像一样。

提示

  • 像素的 rgbtRedrgbtGreenrgbtBlue 组件的值都是整数,因此在将任何浮点数分配给像素值时,请务必将其四舍五入到最近的整数!

视频讲解

请注意,此播放列表中有 5 个视频。在 YouTube 上打开

如何测试

请务必在提供的示例位图文件上测试你所有的过滤器!

正确性

check50 cs50/problems/2026/x/filter/more

风格

style50 helpers.c

如何提交

在你的终端中,执行以下命令来提交你的工作,并回答弹出的提示。

submit50 cs50/problems/2026/x/filter/more