过滤器 - CS50x 2026

灰度化的哈佛庭院

待解决的问题

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

一个简单的位图

从这个意义上说,图像就是一个位图(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-less 文件夹中一个名为 helpers.c 的文件中,编写一个程序来为 BMP 图像应用过滤器。

演示

发行代码

在本题中,你将扩展 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.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

红色笑脸

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

明确一下,请记住一个十六进制数字代表 4 位。因此,十六进制的 ffffff 实际上表示二进制的 111111111111111111111111

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

图像过滤

过滤图像到底意味着什么?你可以将图像过滤理解为获取原始图像的像素,并以某种方式修改每个像素,从而使生成的图像呈现出特定的效果。

要求

helpers.c 中实现相关函数,以便用户可以对其图像应用灰度、棕褐色、镜像或模糊过滤器。

  • 函数 grayscale 应接收一张图像,并将其转换为同一图像的黑白版本。
  • 函数 sepia 应接收一张图像,并将其转换为同一图像的棕褐色版本。
  • reflect 函数应接收一张图像,并将其进行水平镜像翻转。
  • 最后,blur 函数应接收一张图像,并将其转换为同一图像的盒状模糊(box-blurred)版本。

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

提示

理解发行代码

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

bmp.h

打开 bmp.h(例如在文件浏览器中双击它)并查看。

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

对你来说最重要的或许是,该文件还定义了一个名为 RGBTRIPLEstruct(结构体),它简单地“封装”了三个字节:一个蓝色、一个绿色和一个红色(请记住,这正是我们在磁盘上实际发现 RGB 三元组的顺序)。

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

filter.c

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

首先,请注意第 10 行对 filters 的定义。该字符串告诉程序允许的命令行参数有哪些:bgrs。它们每一个都指定了我们可以应用于图像的不同过滤器:模糊(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 行的数组,每行本身又是另一个包含 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 图像一样。

实现 grayscale

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

  • 回想一下,如果红色、绿色和蓝色的值都设置为 0x00(十六进制的 0),那么该像素就是黑色的。如果所有值都设置为 0xff(十六进制的 255),那么该像素就是白色的。只要红色、绿色和蓝色的值都相等,结果就是黑白光谱中深浅不一的灰色,值越高意味着颜色越浅(越接近白色),值越低意味着颜色越深(越接近黑色)。
  • 因此,要将像素转换为灰度,你只需要确保红、绿、蓝的值都相同。但是你如何知道将它们设为什么值呢?嗯,如果原始的红、绿、蓝值都相当高,那么新值也应该相当高,这是合理的。如果原始值都很低,那么新值也应该很低。
  • 事实上,为了确保新图像的每个像素仍具有与旧图像大致相同的亮度或黑暗度,你可以取红色、绿色和蓝色值的平均值,以确定新像素的灰色阴影。

如果你将上述算法应用于图像中的每个像素,结果将是转换为灰度的图像。写一些伪代码来帮助你解决这个问题。

void grayscale(int height, int width, RGBTRIPLE image[height][width])
{
    // 遍历所有像素

        // 计算红、绿、蓝的平均值

        // 更新像素值
}

首先,你如何遍历所有像素?回想一下,图像的像素存储在二维数组 image 中。要迭代二维数组,你需要两个循环,一个嵌套在另一个里面。

void grayscale(int height, int width, RGBTRIPLE image[height][width])
{
    // 遍历所有像素
    for (int i = 0; i < height; i++)
    {
        for (int j = 0; j < width; j++)
        {
            // 计算红、绿、蓝的平均值

            // 更新像素值
        }
    }
}

现在,你可以使用 image[i][j] 来访问图像的任何单个像素。但是如何取红、绿、蓝元素的平均值呢?回想一下 image 的每个元素都是一个 RGBTRIPLE,它是 bmp.h 中定义的表示像素的结构体。访问结构体成员的通常语法适用,其中 image[i][j].rgbtRed 将允许你访问 RGBTRIPLE 的红色值,image[i][j].rgbtGreen 将允许你访问其绿色值,依此类推。

计算平均值时,请记住像素的 rgbtRedrgbtGreenrgbtBlue 分量的值都是整数。因此,在将浮点数分配给像素值时,请务必将其四舍五入为最接近的整数!另外,为什么你可能想将这些整数之和除以 3.0 而不是 3 呢?

一旦你将像素的红、绿、蓝值平均为生成的灰度颜色,就可以更新像素的红色、绿色和蓝色值了。到目前为止,你已经熟悉赋值语法了!

实现 sepia

大多数图像编辑程序都支持“棕褐色(sepia)”过滤器,它通过使整个图像看起来有点红褐色,赋予图像一种老旧的感觉。

  • 将图像转换为棕褐色可以通过获取每个像素,并根据三个原始值计算新的红色、绿色和蓝色值来实现。

  • 有许多种将图像转换为棕褐色的算法,但对于本题,我们要求你使用以下算法。对于每个像素,棕褐色颜色值应根据以下原始颜色值计算。

    sepiaRed = .393 * originalRed + .769 * originalGreen + .189 * originalBlue
    sepiaGreen = .349 * originalRed + .686 * originalGreen + .168 * originalBlue
    sepiaBlue = .272 * originalRed + .534 * originalGreen + .131 * originalBlue
    
  • 当然,这些公式中每一个的结果可能不是整数,但每个值都可以四舍五入到最接近的整数。公式的结果也有可能是一个大于 255 的数字(8 位颜色值的最大值)。在这种情况下,红、绿、蓝的值应该被限制在 255。因此,我们可以保证生成的红、绿、蓝值将是 0 到 255(含)之间的整数。

写一些伪代码来帮助你解决这个问题,并回想一下使用嵌套 for 循环来访问每个像素。

void sepia(int height, int width, RGBTRIPLE image[height][width])
{
    // 遍历所有像素
    for (int i = 0; i < height; i++)
    {
        for (int j = 0; j < width; j++)
        {
            // 计算棕褐色值

            // 使用棕褐色值更新像素
        }
    }
}

要计算 sepia 值,请重新审视上面的要点。你已经有了计算棕褐色值的公式,但仍有一些注意事项。具体来说,你需要……

  • 将每次计算的结果四舍五入到最接近的整数
  • 确保生成的值不大于 255

在实现 sepia 时,特别是当你需要确保颜色的值不高于 255 时,一个返回两个整数中较小者的函数如何能派上用场?欢迎(但不要求)你自己编写一个辅助函数来实现这一点!

实现 reflect

某些过滤器可能还会移动像素。例如,镜像一张图像是一种过滤器,生成的图像就像是将原始图像放在镜子前得到的一样。

  • 图像左侧的任何像素都应该最终出现在右侧,反之亦然。
  • 请注意,原始图像的所有原始像素仍将存在于镜像图像中,只是这些像素可能已被重新排列到图像中的不同位置。

reflect 函数中,你需要交换一行中相对两侧像素的值。写一些伪代码来帮助你开始:

void reflect(int height, int width, RGBTRIPLE image[height][width])
{
    // 遍历所有像素
    for (int i = 0; i < height; i++)
    {
        for (int j = 0; j < width; j++)
        {
            // 交换像素
        }
    }
}

回想一下课堂上我们如何使用临时变量实现两个值的交换。除非你想,否则不需要使用单独的交换函数!

现在是思考嵌套 for 循环的好时机。外层 for 循环迭代每一行,而内层 for 循环迭代该行中的每个像素。然而,为了成功地镜像一行,你需要迭代其中的每个像素吗?

实现 blur

有很多方法可以创建模糊或柔化图像的效果。对于本题,我们将使用“盒状模糊(box 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])
{
    // 创建图像副本
    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 上打开

如何测试

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

正确性

check50 cs50/problems/2026/x/filter/less

样式

style50 helpers.c

如何提交

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

submit50 cs50/problems/2026/x/filter/less