数字图像处理这门课,前面几节课都在讲理论,到了实验五真正动手写代码的时候,很多人反而懵了:公式都懂,怎么用Python把这些离散傅里叶变换、滤波器、边缘检测落到一张图上?我这次实验五做的是基于Python的频域滤波与边缘检测综合实验,从频谱分析到低通高通滤波,再到Sobel和Canny边缘提取,一条链路全部跑通。这篇内容就围绕这个实验展开,把我在实现过程中踩过的坑、调过的参数、以及每个操作背后的原理都摊开讲,无论你是正在赶实验报告的学生,还是想快速把OpenCV图像处理链路跑通的开发者,应该都能直接抄作业。
1. 实验五到底要做什么:目标拆解与整体设计思路
1.1 先搞清楚实验考察的核心能力
实验五通常不会太难,但考察点非常集中。我拿到题目后第一件事不是写代码,而是拆题目:这个实验表面上要求你“实现图像滤波和边缘检测”,实际上考的是三件事。
第一,是否理解图像在频域里长什么样。很多同学把傅里叶变换当成一个黑盒子,fft完事就贴图,却不知道频谱图上的亮点、十字线、中心亮斑分别代表什么。实验五的第一个隐藏考点就在这里:你能不能解释清楚频谱图,并且根据频谱特征反推原始图像的内容。
第二,是否掌握滤波器构造的基本套路。频域滤波的核心不是调库函数,而是“构造一个和图像尺寸相同的频域掩模矩阵”,然后和频谱做逐元素乘法。有人用OpenCV的filter2D做空域卷积来代替,这样虽然能出效果,但实验五的评分要点往往在于你亲手构造了频域滤波器,并且对比了不同截止频率带来的影响。
第三,是否理解空间梯度与边缘检测的等价关系。频域高通滤波提取的边缘,和Sobel、Canny在空域提取的边缘,在数学本质上是相通的:都是对灰度变化剧烈的区域做增强。实验要求你同时跑两类方法并对比,目的就是训练这种“空域和频域坐标系切换”的直觉。
拆完这三个点,你就知道实验报告应该怎么写、代码应该怎么组织。很多同学报告写得差,不是因为实验没做完,而是因为不知道在讲什么,全程粘贴代码,没有思想。
1.2 方案选型:为什么是Python + OpenCV + NumPy
做数字图像处理,语言选择上基本没有悬念,就是Python。原因很直接:OpenCV封装了绝大多数经典算法,NumPy提供矩阵运算底层,Matplotlib负责可视化,三者组合起来,写一个完整实验不超过两百行代码。
有人问为什么不用MATLAB,其实MATLAB在图像处理教学里也很好用,但Python生态更开放,后续接深度学习、数据分析都方便。这门课既然定了Python,我就选OpenCV作为主力库。需要注意一点,OpenCV有两个Python发行包,一个叫opencv-python,一个叫opencv-contrib-python,实验五只用到基础模块,装opencv-python就足够了,contrib版本会多装一些扩展功能,反而增加依赖体积。
NumPy是整个实验的骨架。图像读进来就是一个ndarray,傅里叶变换返回的复数矩阵也是ndarray,滤波器的构造、掩模的点乘、数据的归一化,每一步都在和NumPy打交道。尤其要注意OpenCV的默认图像数据类型是uint8,而傅里叶变换后的频谱是float64复数,二者混在一起操作会直接报错或者出诡异图像,后面我会专门讲这个坑。
1.3 完整处理链路怎么设计
我设计的实验流程是:先读入一幅灰度图像,做二维离散傅里叶变换并中心化,可视化频谱;然后构造理想低通、高斯低通两种频域滤波器,观察不同截止频率下的重建效果;接着用高通滤波器提取图像高频分量,和空域的Sobel梯度、Canny边缘结果做对比;最后把所有结果拼成一张对比图,写进实验报告。
这个链路的好处是逻辑递进清晰:从频谱分析到频域滤波,再到空域边缘检测对照,每个阶段的输出都是下一阶段的输入。你不需要单独为每个算法准备一堆杂乱代码,而是用一个脚本串起来。我实际测试下来,处理512乘512的灰度图,整条链路跑完不到两秒,性能完全不是问题。
实现的时候有一个顺序容易搞错:一定先做灰度化,再做傅里叶变换。如果输入是三通道彩色图,直接np.fft.fft2(img)会把三维数组整个变换,得到的频谱含义不清晰,可视化也乱。实验五的算法基本都是针对单通道灰度图像的,彩色图像要处理的话一般是每个通道分别处理,但初学者没必要在这个阶段自找麻烦。
2. 核心原理:频域滤波与边缘检测的那些事
2.1 傅里叶变换在图像里到底看到了什么
图像傅里叶变换的数学表达式是二维DFT,但理解上完全可以用生活类比。一张图片本质是一个二维函数,横纵坐标是像素位置,函数值是灰度。傅里叶变换就是把这张图片分解成一系列不同频率、不同方向的正弦波叠加。图像里灰度变化缓慢的平坦区域,对应低频分量;灰度突变剧烈的边缘和细节,对应高频分量;孤立噪声点,则是极高频率的尖峰。
这里有一个高频和位置无关的直觉:你换一个噪声点,比如从左上角挪到右下角,频谱的幅度不会改变,只是相位改变。实验里通常只看幅度谱,因为里面包含了图像的能量分布信息,相位谱包含空间位置信息,但对初学者来说意义不明显。
幅度谱的动态范围极大,中心直流分量可能达到十几万,而高频分量只有几十,直接显示会变成一片白中心加几个黑点。所以要加一个对数变换:np.log(1 + np.abs(shifted_spectrum)),把大跨度数据压缩到可显示范围。这个操作不是算法的一部分,是为了可视化,但几乎每个实验都会踩到这个坑,不取对数频谱图根本看不清。
2.2 低通和高通滤波:一个磨皮,一个找边缘
低通滤波的思路很简单:在频域里让低频通过,把高频衰减掉。我用频域掩模H(u,v)与原频谱逐元素相乘,再逆变换回空域。掩模的实现有两种:理想低通是圆形区域内为1、区域外为0的硬截断;高斯低通是平滑衰减的高斯函数。
理想低通的问题是截断会产生振铃效应,也就是图像边缘附近出现一圈圈明暗交替的波纹。原因是频域矩形截断等价于空域卷积一个sinc函数,sinc带有明显的振荡尾巴,振荡在图像边缘处尤其明显。高斯低通没有硬边界,频域平缓衰减,空域对应的高斯核也是平滑的,所以不会振铃。实验里一定要对比这两种滤波器,才能直观理解为什么工程上都在用高斯或巴特沃斯,而不是理想滤波器。
高通滤波和低通对称,高通就是保留高频、衰减低频。实现上最简单的方式是H_high = 1 - H_low,但要注意这会把直流分量也完全去掉,图像整体变成接近零灰度,只有边缘是亮线。工程上更常用的是高频增强,表达式为H = 1 + k * H_high,保留一部分低频作为基底,让边缘又清楚又不会失去整体轮廓。实验中两种我都在代码里跑了一遍,效果差异很明显。
2.3 边缘检测的梯度逻辑与Canny全流程
频域高通提取边缘,空域梯度算子做的是同一件事,只是坐标系不同。图像某个位置的梯度是一个二维向量,幅值代表灰度变化剧烈程度,方向代表变化方向。Sobel算子就是用两个卷积核分别计算水平梯度Gx和垂直梯度Gy,然后求幅值sqrt(Gx²+Gy²)。
Sobel的优点是计算快、原理直白,缺点是提取出的边缘通常比较粗,而且对噪声敏感。Canny在Sobel基础上增加了三个关键步骤:先用高斯核平滑去噪;然后做非极大值抑制,把梯度方向上不是局部最大值的像素置零,让边缘变细;最后用双阈值加滞后连接,高阈值确定强边缘,低阈值弥补弱边缘的断裂。
Canny的两个阈值怎么选,是实验里最玄学的地方。阈值太高,边缘断裂严重;阈值太低,噪声全被当成边缘。我的经验是先看直方图或梯度幅值分布,高阈值取梯度幅值的大约70到80分位,低阈值取高阈值的二分之一到三分之一,然后根据结果微调。这个手感比单纯背参数重要得多。
3. 环境准备与测试数据构造
3.1 依赖安装与版本踩坑
实验开始前先把环境装干净。我用的是Python 3.10,配合以下版本组合运行没有出现问题:
| 库名 | 版本 | 用途 |
|---|---|---|
| numpy | 1.24以上 | 矩阵运算与傅里叶计算 |
| opencv-python | 4.8以上 | 图像读写与边缘检测 |
| matplotlib | 3.7以上 | 结果可视化 |
| scipy | 选用 | 高斯核生成辅助 |
安装命令很简单:
pip install numpy opencv-python matplotlib scipy有一个经常遇到的问题:OpenCV在Python里的包名是cv2,导入语句写import cv2。很多新手在这里反复折腾,以为要装名为opencv的包,实际上并没有这个包名。另外,如果电脑上同时装了Anaconda和系统的Python,容易把库装到不同的环境里,建议全程用同一个虚拟环境,避免出现“代码报错但库明明装过”的诡异情况。
3.2 测试图像的两种选择
实验五的数据不必非用一张真实照片不可,我准备了两种测试图。
第一种是合成图像,用来验证频域变换的正确性。我用NumPy生成了一张包含水平正弦条纹和垂直正弦条纹叠加的灰度图:
import numpy as np size = 512 x = np.arange(size) y = np.arange(size) xx, yy = np.meshgrid(x, y) # 水平频率10、垂直频率30的正弦条纹叠加 img_sin = (np.sin(2 * np.pi * 10 * xx / size) + np.cos(2 * np.pi * 30 * yy / size)) * 127 + 128 img_sin = img_sin.astype(np.uint8)这种图的好处是频谱特征完全可预测:频谱上会呈现两个方向的对称亮点区,位置对应10和30这两个频率。如果你把各自分量分离后分别做傅里叶变换,再对比混合图的频谱,就能准确验证fft和fftshift的实现有没有问题。
第二种是自然图像,用OpenCV随便拍一张或者找一张标准测试图都行。我找了一张带有明显边缘和纹理的建筑物照片,用来测试低通滤波的平滑效果和Canny边缘检测。自然图更直观,报告里放这种对比图更有说服力。
3.3 读取、灰度化和类型转换的细节
图像的读取和预处理看似基础,但类型问题处理不好,后面每一步都会出幺蛾子。
读取时我使用cv2.imread(path, cv2.IMREAD_GRAYSCALE)直接以灰度模式读图,等于省去一步cvtColor。注意这里有个大坑:cv2.imread对文件路径中的中文字符支持不友好,如果路径里有中文,函数会静默返回None,没有任何报错,然后程序到下一条语句才崩溃。解决办法是用NumPy的fromfile方法读取文件字节流,再用cv2.imdecode解码:
import cv2 import numpy as np def imread_unicode(path, flags=cv2.IMREAD_GRAYSCALE): data = np.fromfile(path, dtype=np.uint8) img = cv2.imdecode(data, flags) return img预处理完毕后,把图像转成float64类型再做傅里叶变换。因为uint8的取值范围是0到255,DFT算出的数值范围是0到15万左右,后续滤波器做归一化时如果还是整型,会频繁截断溢出错乱。我的做法是在读完图之后立即执行img_float = img.astype(np.float64)。
读图还容易忽略通道顺序。如果用了cv2.imread默认彩色模式,返回的是BGR顺序,直接用Matplotlib显示会偏蓝。灰度图没有这个问题,但如果后面想叠加彩色标注,就得记住转成RGB。实验五的算法部分不涉及彩色,但做对比图时容易踩坑。
4. 代码实现与逐段解析
4.1 傅里叶变换与频谱可视化
实验五的核心入口是二维离散傅里叶变换。NumPy的np.fft.fft2直接对二维数组做变换,结果是一个复数矩阵。默认情况下,频谱的低频分量分布在四个角上,用np.fft.fftshift把低频移动到图像中心,这样更方便分析。
完整的频谱分析代码:
import numpy as np import matplotlib.pyplot as plt def show_spectrum(img_gray): img_f = np.fft.fft2(img_gray.astype(np.float64)) img_f_shift = np.fft.fftshift(img_f) magnitude = np.log(1 + np.abs(img_f_shift)) plt.imshow(magnitude, cmap='gray') plt.colorbar() plt.title('Log Magnitude Spectrum') plt.show() return img_f_shift关键点是np.abs返回复数矩阵的模,这是幅度谱;np.log(1 + ...)压缩动态范围。加1是为了防止log(0)产生无效值,频谱某些位置确实会是0。
逆变换的时候要记得先ifftshift把频谱还原回原布局,再进行np.fft.ifft2:
img_recon = np.fft.ifft2(np.fft.ifftshift(filtered_spectrum)) img_recon = np.real(img_recon).astype(np.float64)ifft2的结果理论上应该是实数,但因为浮点误差会残留微小的虚部,用np.real取实部即可。直接取模abs也可以,但会改变数值范围,推荐用real。
4.2 理想低通与高斯低通滤波实现
先构造理想低通掩模。掩模尺寸必须和频谱矩阵完全一致,这里用512 * 512,中心点是(256, 256)。计算每个像素到中心的距离,距离小于截止半径的为1,其余为0:
rows, cols = img.shape crow, ccol = rows // 2, cols // 2 D = np.sqrt((np.arange(rows)[:, None] - crow) ** 2 + (np.arange(cols)[None, :] - ccol) ** 2) # 理想低通,截止半径50 r = 50 H_ideal = (D <= r).astype(np.float64)这段代码的高频技巧在np.arange(rows)[:, None]这个操作,它生成一个列向量,和行向量广播之后直接得到完整的距离矩阵,不需要两层循环,处理512乘512的图也只要几毫秒。
高斯低通掩模表达式如下:
sigma = 30 H_gauss = np.exp(-(D ** 2) / (2 * sigma ** 2))对比两个掩模的可视化效果:理想低通是边缘清晰的圆形白斑,高斯低通是平滑的渐变亮区。滤波操作本身通用,都是简化成逐元素乘法:
filtered_f = img_f_shift * H_mask img_filtered = np.real(np.fft.ifft2(np.fft.ifftshift(filtered_f)))这里要注意,乘法是在中心化后的频谱上进行的,逆变换前必须还原。我调试时忘记ifftshift,结果重建图像分成四块错位排列,第一眼看着还以为是图像翻转的bug,浪费了不少时间。
4.3 高通滤波与高频增强
高通滤波我用两种写法都实验过。第一种是用高斯高通掩模,公式是1 - exp(-D²/2σ²)。第二种是在理想低通基础上取反:H_high_ideal = 1 - H_ideal。
直接高通的结果是低频全被滤除,图像背景接近黑色,只有边缘突出:
H_gauss_high = 1 - np.exp(-(D ** 2) / (2 * sigma ** 2)) filtered_f_high = img_f_shift * H_gauss_high img_high = np.real(np.fft.ifft2(np.fft.ifftshift(filtered_f_high)))想保留一点原始背景信息,用高频增强形式。就是说,将高通结果放大k倍后再加回原频谱,这样可以保持边缘增强的同时不让图像变成纯粹的黑底白线,在视觉上更接近人对“锐化”的认知:
k = 0.5 H_enhance = 1 + k * H_gauss_high filtered_f_enhance = img_f_shift * H_enhancek的取值我试过0.3、0.5、1.0,实验结论是k越小图像越接近原图,k越大边缘越锐利但噪声也随之放大。报告中我建议放一张k取不同值的对比图,这个参数实验本身就是加分项。
4.4 梯度边缘检测算子横向对比
空域边缘检测我实现了三种方法:Sobel梯度幅值、拉普拉斯算子和Canny。
Sobel的关键是区分数据类型,卷积结果可能是负数,所以必须用cv2.CV_64F,不能用默认的uint8:
sobelx = cv2.Sobel(img_gray, cv2.CV_64F, 1, 0, ksize=3) sobely = cv2.Sobel(img_gray, cv2.CV_64F, 0, 1, ksize=3) sobel_mag = cv2.magnitude(sobelx, sobely)cv2.magnitude替代了手写np.sqrt(sobelx**2 + sobely**2),数值更稳定,速度更快。
拉普拉斯是二阶微分算子,对噪声极其敏感,但只需要一次卷积:
laplacian = cv2.Laplacian(img_gray, cv2.CV_64F)Canny是三选一里效果最好也最复杂的:
edges_canny = cv2.Canny(img_gray, threshold1=80, threshold2=160)标准测试图下,Sobel提取出的边缘较粗且包含一定灰度渐变区域;拉普拉斯边缘细但噪声点明显;Canny边缘连续、单像素宽度,视觉效果最接近真实物体的轮廓。三种方法放到一张对比表里,得分会高很多。
4.5 完整测试脚本结构
我的最终脚本按以下顺序组织,每个函数对应一个实验子任务:
# 1. 预处理:读取灰度图,转float64 # 2. 频谱分析:fft2 + fftshift + 可视化 # 3. 低通滤波:理想/高斯对比 # 4. 高通滤波:高斯高通 + 高频增强 # 5. 边缘检测:Sobel/Laplacian/Canny # 6. 结果汇总:subplot拼接大图第6步的可视化汇总直接决定了实验报告的上限。我的排版方式是plt.subplots(3, 4),12个子图依次放置:原图、频谱、理想低通掩模、理想低通结果、高斯低通掩模、高斯低通结果、高通结果、高频增强结果、Sobel、Laplacian、Canny,最后一个放参数对比。这样一张大图覆盖了全部实验点,老师一眼看到全貌。
5. 实验结果记录与参数分析
5.1 频谱图怎么看
合成正弦条纹图的频谱验证了我对傅里叶变换的理解。频率为10的水平条纹,在频谱上表现为距离中心垂直方向偏移的对称亮点;频率为30的垂直条纹,表现为距离中心水平方向偏移的对称亮点。两个信号叠加,频谱上两组亮点同时存在,互不干扰。这验证了DFT的线性性质。
自然图像频谱则明显不同:中心一个耀眼的直流亮斑,向外辐射的亮线呈现出十字状。产生十字线的原因是图像内容在水平方向和垂直方向都存在大量灰度突变,比如墙面边缘、窗户框架。如果你对图片做一点轻微旋转,频谱中的十字也会跟着旋转对应角度,这个特性可以用来做图像配准的初级实验。
判断低通滤波是否生效,最简单的办法就是看重建图像的细节。高频分量被削弱后,图像中的纹理细节变得模糊,边缘变缓,但整体亮度分布保持不变。当你看到图像中窗户框变成一块模糊灰带时,就说明低通起作用了。
5.2 截止频率选多少合适
我测试了理想低通在半径20、50、100下的输出效果。半径20的结果让人印象深刻:图像几乎只剩大块色斑,细节全部消失,边缘出现明显波纹。半径50时细节保留一部分,对比度下降。半径100时基本看不出变化。
这里有一个值得写进报告的结论:截止频率越低,空域振铃越严重。高斯低通采用sigma值10、30、60同样做了测试:sigma10图像已经非常模糊但无振铃;sigma30保留大部分主要边缘,纹理细节化开;sigma60效果接近原图。高斯滤波器最实用的区间是sigma30到50之间,比同等级别的理想滤波器更平滑。
由这些数据可以看出,哪有什么“最佳截止频率”,不同图像内容差异太大,关键是理解参数和效果的连续变化关系。建议报告中以曲线或者小图矩阵的形式展示这一连续变化,而不是只有一张结果图。
5.3 边缘检测阈值怎么调
Canny阈值是最容易调到手软的参数。我用梯度幅值直方图辅助定位:先算Sobel幅值,画出它的直方图,观察从哪个数值开始幅值迅速下降。在我的测试图上,梯度幅值在80到160之间分布最多,所以初选threshold1=80, threshold2=160,正好对应低阈值和高阈值的经典比例1比2。
高阈值决定什么算“确定的边缘”,提高它可以直接滤除细小噪声和弱纹理。低阈值决定从高阈值检测到的边缘连出去的范围,降低它可以让断裂的边缘连起来,但过低的代价是噪声也被连进边缘。
实验中发现,低阈值与高阈值的比例比绝对数值更重要。保持固定高阈值,把低阈值从20改到120,边缘的数量显著减少,说明低阈值因为连接规则的下限提升,原本连的上来的弱边缘被丢弃了,断裂逐渐出现。
还有一个小细节:Canny内部自带高斯滤波,所以不要在调用之前再做一次强烈的高斯模糊,否则边缘位置会发生偏移,线条变粗,弱边缘直接消失。我一开始先对图像做了sigma3高斯再Canny,结果边缘反而不如直接用原图做Canny清楚。
6. 常见问题与排查技巧实录
6.1 频谱图一片黑或一片白
这个问题几乎每个人都碰到过。全黑的原因是直接显示np.abs(img_f_shift),由于直流分量占据绝大部分动态范围,imshow的默认归一化把大值映射为白色、其他值全映射为接近黑色,看起来就像一片白中心加黑点;反过来如果数组里有异常大的值,也可能整体偏黑。
解决办法就是取对数压缩。我习惯用np.log(1 + np.abs(...)),这里加1不只是为了防止log0,也让最小值为0,避免显示负值。如果你觉得压缩后对比度还是不够,可以再用cv2.normalize做一次线性拉伸到0到255。
import cv2本身长度很短,很多人直接从网上复制代码没看导入语句。建议所有脚本开头统一写下四行导入,避免中间用到哪个函数才想起来没导入。
6.2 滤波后图像出现振铃波纹
理想低通滤波后的重建图,边缘一圈圈像水波纹,这是振铃效应。原因前面说过,理想滤波器的频域硬截断对应空域sinc核的振荡。我在实验报告里专门写了一段结论:理想低通的截止频率越小,振荡周期越长、振幅越大。半径50时振铃轻微,半径20时振铃已经严重影响观感。
如果实验要求必须用理想低通,可以尝试先对掩模边缘做一个小过渡带,比如用平滑的斜坡来代替0和1的突变,这样就能大幅抑制振铃。工程上这是巴特沃斯滤波器的思想,实验中也可以作为扩展内容,老师通常很乐意看到。
6.3 Canny边缘断裂或噪声多
边缘断裂的主要原因是高阈值过大,把一部分弱边缘判成了非边缘。调低高阈值、或者把低阈值往上提一点,可以改善。如果图像本身噪声不多但纹理很丰富,例如树叶、毛发,Canny会把纹理也当成边缘,看起来“噪声多”,此时应当先稍微做一点高斯模糊,把细纹理抹掉。
另一种情况是图像对比度很低,梯度幅值整体偏小,阈值按常规经验取80/160,结果什么都没有。这时可以先用归一化增强对比度,或直接看梯度直方图,把阈值选在20/40这样的低数值区间。记住阈值是相对数据分布的参数,不是通用常熟。
6.4 imread读不到图片
代码报错AttributeError: 'NoneType' object has no attribute ...,十有八九是图像路径不对,或者路径中有中文字符,读进来是None。解决办法是先用os.path.exists(path)检查文件存在性,再打印读到的对象是否为空。如果要用中文路径,就用之前提供的imread_unicode函数。
6.5 数据类型不一致导致显示异常
调用cv2.imshow或plt.imshow显示浮点图像时,图像数据范围超出0到1或者0到255,显示出来会是一块白或一块黑。OpenCV的imshow对浮点图像默认按0到1范围理解,而我的滤波结果通常取值范围是0到255,所以显示时几乎全白。
统一做法是在显示前归一化:
def normalize_img(img): img = img.astype(np.float64) img = (img - img.min()) / (img.max() - img.min() + 1e-10) return img这里加一个极小量防除零。归一化之后保存或显示都不会出问题。如果后续要保存为uint8图像,再乘255转回整型。
7. 写在最后:几个让实验结果更漂亮的细节
这节分享一些不在实验要求里、但能让报告和代码更专业的细节。
第一是统一坐标系。所有的滤波器掩模都基于同一个距离矩阵D来构造,不仅避免重复计算,还能保证不同滤波器实验的“频率标定”一致,排除变量。写报告时可以明确说明“所有滤波器基于相同频域坐标生成”,这句话是你的实验设计严谨性的体现。
第二是保存图片时提高DPI。Matplotlib默认的保存图片只有100DPI,放进报告会有点模糊。保存大图时用plt.savefig('result.png', dpi=200, bbox_inches='tight'),线条和文字会清晰很多,打印或者投影都好看。
第三是给每个子图添加字母标号,类似(a) 原图、(b) 频谱、(c) 高斯低通这种格式。实验报告的评阅老师往往要扫过几十份相似作业,清晰标注能让你的内容更容易被注意到。
我在实验五里面最大的感悟是:这些频域操作看起来高深,但拆开之后每一步都是初等数学和基本矩阵操作的组合,真正难的是调试时保持对数据“在哪个坐标系、什么取值范围”的清醒认知。图像处理实验的代码量不大,却是一个极好的思维训练过程。如果你也在做这个实验,不要满足于把代码跑通,试着改参数、加对比、写分析,收获会远超作业本身。