有人拿着从网页上右键另存的一张 512×512 人像测试图跑 PSNR 对比,结果和论文里的参考值差了将近 3dB,排查了一下午算法参数,最后发现问题出在基准图本身——那张图被网页端重新压过一遍,块效应已经渗进了每一处平滑区域。这事我在不同的团队里见过不止一次,所以当我看到"Lena 图片资源库"这个标题时,第一反应不是"这不就是一张图吗",而是一个合格的 Lena 图片资源库到底该长什么样。这张图在图像处理、压缩编码、去噪、超分辨率这些方向被当成事实标准用了三十多年,几乎每个做底层视觉的人都拿它跑过第一个 Demo。但它好用不等于随便拿一张就能用,资源库的价值恰恰在于把"随便拿一张"这件事变成"有据可查、有校验、可复现"。下面这些内容适合刚进实验室要跑第一个实验的同学,也适合已经在写论文、被审稿人问过"你基准图哪来的"的从业者。
1. 这张512×512的测试图,凭什么在底层视觉圈活了三十多年
1.1 它被选中的真实原因:一张图凑齐了所有难处理的图像特征
很多人以为它出名是因为"有名",其实最初的选型逻辑非常工程化。图像处理算法在七八十年代要验证的是滤波、变换、量化这一套东西,而一套验证素材最理想的状态是:单张图里同时包含低频大片平滑区、中频规则纹理、高频锐利边缘,以及接近真实人眼敏感度的人脸区域。这张图刚好全占了:肩部和背景的大面积渐变属于低频,适合检验量化是否产生可见的阶梯;帽檐的羽毛是典型的高频密集纹理,任何一点滤波器的过平滑都会在这里暴露;帽子上的环状装饰是周期性规则纹理,最容易诱发摩尔纹和振铃;而人像的皮肤过渡区介于低频和高频之间,是检验色度子采样、肤色校正、块效应最敏感的区域。
换成纯几何图,测不出自然图像的统计特性;换成纯风景图,人脸区域的视觉敏感度又覆盖不到。一张图能把这几类特征同时摆出来,在那个存储和算力都极其紧张的年代,实用价值远大于"好看"。
1.2 512×512 这个尺寸不是随手定的
尺寸的确定带着很强的时代烙印。一张 512×512 的 8 比特三通道彩图,原始数据量是 512×512×3 = 786,432 字节,也就是 768KB;灰度版本是 262,144 字节,正好 256KB。这个数字在当年是有讲究的:它是 2 的整数次幂,做 DCT、小波分解、金字塔下采样时不需要考虑边界填充带来的额外复杂度;它又刚好能被 8×8 和 16×16 的块完整整除,块划分实验不会留下残块;同时它小到能在内存只有几百 KB 的机器上反复读写,不大到让一次实验跑一整夜。
| 属性 | 数值 | 对实验的意义 |
|---|---|---|
| 尺寸 | 512×512 | 可被 8/16/32/64 整除,块类算法无残块 |
| 彩色原始体积 | 768KB | 当年可放入内存反复读写 |
| 灰度原始体积 | 256KB | 便于快速迭代 |
| 位深 | 8 bit/通道 | 覆盖 0-255 全动态范围,无明显过曝欠曝 |
| 通道 | RGB 三通道 | 可同时验证亮度与色度处理 |
这三条合起来,就是它能在几十年里一直作为"第一张跑通图"的原因。你要理解一件事:它不是标准,它是习惯。没有任何标准组织规定过它,纯粹是社区默契。
1.3 它测不出来的东西,比它能测出来的更值得警惕
用久了必须清楚它的盲区,否则论文里很容易出现"在 Lena 上效果好"但实际上算法已经偏了的情况。第一,它没有大面积暗部,噪声在低照度下的表现完全测不出来,做夜景降噪的拿它调参基本等于没调。第二,它的动态范围偏中等,高光和深阴影都被裁剪掉了,做 HDR 色调映射的用它没有意义。第三,它的纹理种类虽然多,但全是自然纹理,缺少文字、线条、建筑这类人造结构,做文档图像增强或者屏幕内容编码的根本不该用它。第四,也是最容易被忽略的一点:只有一张图,样本量为 1,任何结论都不具备统计意义。
我见过有人拿这一张图的 PSNR 差 0.1dB 就宣称方法更优,这在审稿里会被直接质疑。比较稳妥的做法是把它当"冒烟测试"用——算法能不能跑、有没有明显崩溃、中间结果可不可视化,用它最快;真正的量化结论必须换到多图集合上,比如经典的图像处理测试图集或者自己构造的合成数据集。这一点我在实际项目里被坑过:早期用单图调出来的参数,换到真实业务图上全线崩盘,因为那张图恰好没有真实数据里的暗部噪声和强压缩伪影。
2. 一个真正能用的 Lena 图片资源库,目录里该放些什么
2.1 基础层:无损原图与它的灰度派生
资源库的第一层必须是"根",也就是未经任何有损处理的位图。这里有个硬性要求:存储格式只能是 PNG、TIFF、BMP 这类无损容器,绝对不能放 JPG。原因很直白,JPG 是有损的,量化表一旦烧进去就无法还原,你后面所有的实验基准都被污染了。很多所谓的"免费下载"页面给的其实是质量 90 左右的 JPG,用它当 ground truth 算 PSNR,天花板直接压到 40dB 出头,算法再好也测不出来。
灰度版本也要单独收一份,而且要注明生成方式。工业界常见两种:一种按 ITU-R BT.601 的加权 0.299R + 0.587G + 0.114B,另一种按 BT.709 的 0.2126R + 0.7152G + 0.0722B。两者算出来的灰度图差异肉眼可见,如果你对比的论文用的是 601 而你用 709,PSNR 能差零点几 dB,足够让复现失败。我建议资源库里两种都存,文件名后缀标注清楚,例如lena_gray_601.png和lena_gray_709.png。
有一个细节值得单独说:原始彩色图本身也有一份"官方"版本和无数"衍生"版本。有人在历史上对它做过色彩校正、锐化、重新扫描,导致不同来源的同一张图色彩分布不一样。资源库必须保留一份被广泛引用的基准版本,并在清单里写明它的来源标注,否则你根本无法判断别人的实验用的是哪一版。
2.2 派生层:多分辨率、质量梯度与退化版本
第二层是"实验素材",这一层才是资源库真正的价值所在。手工去造这些版本既费时又容易造得前后不一致,资源库一次生成好,所有人拿到的都是同一套。
| 类别 | 建议收录的档位 | 生成方式说明 | 典型用途 |
|---|---|---|---|
| 多分辨率 | 512 / 256 / 128 / 64 | 面积平均下采样,非最近邻 | 金字塔、多尺度实验 |
| JPEG 质量梯度 | Q=95/85/75/50/25/10 | 固定量化表,记录实际码率 | 压缩伪影去除 |
| 高斯噪声 | σ=5/10/25/50 | 固定随机种子 | 去噪、盲去噪 |
| 椒盐噪声 | 密度 1%/5%/10% | 固定随机种子 | 中值滤波类验证 |
| 高斯模糊 | σ=0.5/1.0/2.0 | 固定核大小与边界处理 | 去模糊、锐化 |
| 下采样-上采样 | ×2、×4 | 先 bicubic 下采样再上采样 | 超分退化建模 |
这里有个坑必须提前说:多分辨率版本一定是下采样生成的,不是原生拍摄的高分辨率。有些资料站会提供 1024×1024 的"高清 Lena",那基本都是插值放大的结果,拿去当超分任务的 ground truth 等于自己给自己造了一个不存在的真值。资源库在命名上就该写清楚,比如lena_512_down2_256.png,让人一眼看出这是派生而不是原生。
噪声版本一定要存随机种子。我见过一个团队换人之后重跑实验,去噪结果对不上,查了两天发现是加噪时没固定种子,每次跑都是新噪声。这种低级问题在资源库里一次性解决掉,写进生成脚本,种子作为参数固定下来,后面所有人都省事。
2.3 清单层:manifest 与校验文件
第三层最容易被忽略,但恰恰是"资源库"和"一堆图片"的分界线。至少要有一份 JSON 清单,记录每个文件的相对路径、尺寸、通道数、位深、色彩空间、文件大小、SHA256,以及派生关系(父文件是谁、用了什么参数)。
{ "asset": "lena_color_512.png", "width": 512, "height": 512, "channels": 3, "bit_depth": 8, "color_space": "sRGB", "bytes": 473831, "sha256": "…", "derived_from": null }有了这份清单,别人下载之后跑一个校验脚本就能知道文件有没有损坏、有没有被中间环节重新压缩过。这一点在大文件分享过程中尤其重要,某些分享渠道会对图片做自动转码或者加水印,一旦发生,尺寸和哈希都会变,脚本一跑就露馅。
3. 文件到手先别急着写代码:三个必须先做的校验动作
3.1 哈希校验:确认文件真的没在中途被"加工"过
下载完第一件事是算哈希,和清单对比。这不是形式主义,我遇到过太多次分享链路对图片做了不可见的重新编码。校验方式很简单:
# Linux / macOS shasum -a 256 lena_color_512.png # Windows PowerShell Get-FileHash .\lena_color_512.png -Algorithm SHA256哈希对不上,先别急着怀疑文件损坏,八成是被某个环节重新编码了。这时候正确的做法不是凑合用,而是换一个下载源重新取。压缩包里如果有多个文件,写个循环一次性全查:
import hashlib, json, pathlib def sha256_of(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() manifest = json.load(open("manifest.json", encoding="utf-8")) bad = [] for item in manifest: p = pathlib.Path(item["asset"]) if not p.exists() or sha256_of(p) != item["sha256"]: bad.append(str(p)) print("异常文件:", bad if bad else "无")3.2 尺寸、通道与真实编码格式的交叉验证
哈希对上只能说明"文件没变",不能说明"文件是对的"。第二步要验证它是不是你以为的那个东西。扩展名是最不可信的信息,lena.png完全可能内部是 JPEG 数据被改了后缀。用库读一下真实编码:
from PIL import Image im = Image.open("lena_color_512.png") print(im.format, im.size, im.mode, im.info.keys()) # 期望类似: PNG (512, 512) RGB dict_keys(['dpi'])如果im.format打印出JPEG,那这个文件就是假的 PNG。如果im.mode是P或L而不是RGB,说明它是调色板图或灰度图,通道数不对。这一步看着傻,但能挡掉大量"看着像其实不是"的问题。
3.3 色彩空间与嵌入配置的排查
第三件事最隐蔽:检查有没有嵌 ICC 配置文件、有没有 EXIF 里的方向标记、有没有 alpha 通道。ICC 配置会影响不同软件里的显示和像素读取结果;EXIF 方向标记会让某些读取库自动旋转 90 度,导致你的尺寸检查莫名其妙失败;多出来的 alpha 通道会让shape变成(512, 512, 4),直接打断后续的三通道假设。
| 异常现象 | 根因 | 处理办法 |
|---|---|---|
shape是 4 通道 | 带 alpha 通道 | 显式转 RGB 或丢弃 alpha |
| 读取后尺寸变 512×512 以外的值 | EXIF 方向被自动应用 | 读取时禁用自动旋转 |
| 不同软件里颜色明显偏 | 嵌入了非 sRGB 的 ICC | 统一转到 sRGB 后再处理 |
| 平滑区有细密网格 | 曾被 JPEG 压缩 | 换源,不要试图修复 |
| 哈希每次都不同 | 分享链路自动转码 | 换分发方式 |
我个人的习惯是资源库里不放带任何附加信息的文件,转成最朴素的 PNG,剥掉 EXIF 和 ICC,只标一个"假定 sRGB",这样跨工具链的行为最稳定。
4. 拿它跑客观质量评测时,五个反复翻车的细节
4.1 基准图本身带损,是所有对比失效的头号原因
这是最高频的问题,前面反复提过但值得单列。判断方法很简单:打开图片,把画面放大到 400%,看肩部那片平滑渐变区。如果有 8×8 的方块痕迹或者颜色过渡出现台阶,那就说明它被有损压缩过。这个判断比任何脚本都直观。
一旦基准带损,你得到的 PSNR 曲线会整体下移并且被"压平"——因为基准本身已经模糊,算法把结果变得越锐利,反而离这个模糊基准越远。这时候你会看到一些反直觉的现象:视觉效果更好的方法,PSNR 反而更低。别急着归因于算法,先查基准。
4.2 通道顺序和数据类型,一个字母之差结果全变
RGB 和 BGR 搞混,是第二高频。用 OpenCV 读图得到的是 BGR 顺序,用 PIL 读到的是 RGB,两者混用算出来的 PSNR 会莫名其妙偏低。判断技巧:单独算三个通道的 PSNR,如果红通道和蓝通道的数值明显对调了,那就是通道顺序错了。
数据类型的问题同样致命。8 比特整型直接相减会产生下溢回绕,255 减 0 变成 1 这种错误不会报错,只会让结果混乱。正确做法是先转浮点再算:
import numpy as np from PIL import Image def psnr(ref_path, test_path): ref = np.asarray(Image.open(ref_path).convert("RGB"), dtype=np.float64) tst = np.asarray(Image.open(test_path).convert("RGB"), dtype=np.float64) assert ref.shape == tst.shape, (ref.shape, tst.shape) mse = np.mean((ref - tst) ** 2) if mse == 0: return float("inf") return 10 * np.log10(255.0 ** 2 / mse)注意convert("RGB")这一句不能省,它同时解决了调色板模式和 alpha 通道两个问题。
4.3 数据类型与归一化范围不统一
有的库输出 0-1 的浮点,有的输出 0-255 的整型。如果你用 0-1 的图和 0-255 的图比,PSNR 会掉到一个荒谬的负数或者几十 dB 的离谱值。资源库的清单里应该明确写清楚"像素范围 0-255 的 uint8",作为一条全局约定。写脚本时在入口处断言一下:
assert ref.max() > 1.5 and tst.max() > 1.5, "疑似 0-1 归一化,请统一到 0-255"4.4 裁剪对不齐块边界,块类算法的评测全部失真
做块效应去除、DCT 域处理、分块超分这类算法时,如果输入图的左上角位置和基准对不齐,块边界就会错位,效果会凭空掉一大截。资源库里的裁剪块应该按固定步长、固定起点生成,并且把起点坐标写进文件名,例如patch_x000_y000_64.png。别用随机裁剪做评测集,随机性会让两次实验不可比。
4.5 上采样版本冒充原图,超分任务的评测整体虚高
这条前面提过但值得再强调一次,因为它造成的错误最隐蔽也最普遍。判断方法:看图像的高频细节,真正的高分辨率图在头发丝和羽毛边缘会有自然的、不规则的细节;插值放大的图在这些位置会显得平滑但不自然,边缘有轻微的"糊边"。
有一个更技术化的判断手段:算一下图像的功率谱,插值放大的图在高频段会有明显的衰减或者规律的零点。不方便算的话,看文件大小也能有个大概——同等尺寸下,插值放大的 PNG 通常比真实高清图小得多,因为高频信息是人为补出来的,熵更低。
5. 从资源库到实验流水线:把图真正喂进评测脚本
5.1 目录约定与批量加载
资源库落到本地之后,先固定一套目录约定,别让人肉记忆路径:
lena_repo/ raw/ lena_color_512.png lena_gray_601.png lena_gray_709.png scales/ lena_256.png lena_128.png lena_64.png jpeg/ lena_q95.jpg lena_q75.jpg lena_q25.jpg noise/ lena_g10_s1.png lena_g25_s1.png blur/ lena_blur1.0.png patches/ patch_x000_y000_64.png manifest.json加载的时候按 manifest 走,不要os.listdir一把梭。文件名的排序在跨平台时不可靠,而且派生关系和参数都藏在名字里,靠字符串解析很容易出错。
5.2 一条能跑的评测流水线
下面这段是可直接复制的完整脚本,从资源库读取基准,批量施加压缩和噪声,输出 CSV:
import csv, io, json import numpy as np from pathlib import Path from PIL import Image ROOT = Path("lena_repo") REF = ROOT / "raw" / "lena_color_512.png" def load_rgb(p): return np.asarray(Image.open(p).convert("RGB"), dtype=np.float64) def psnr(a, b): mse = np.mean((a - b) ** 2) return float("inf") if mse == 0 else 10 * np.log10(255.0 ** 2 / mse) def jpeg_roundtrip(arr, quality): buf = io.BytesIO() Image.fromarray(arr.astype(np.uint8)).save(buf, format="JPEG", quality=quality) buf.seek(0) return np.asarray(Image.open(buf).convert("RGB"), dtype=np.float64) def add_gauss(arr, sigma, seed=1): rng = np.random.default_rng(seed) return np.clip(arr + rng.normal(0, sigma, arr.shape), 0, 255) def main(): ref = load_rgb(REF) assert ref.shape == (512, 512, 3), ref.shape rows = [] for q in (95, 85, 75, 50, 25, 10): rows.append({"op": f"jpeg_q{q}", "psnr": round(psnr(ref, jpeg_roundtrip(ref, q)), 3)}) for s in (5, 10, 25, 50): rows.append({"op": f"gauss_s{s}", "psnr": round(psnr(ref, add_gauss(ref, s)), 3)}) with open("eval_result.csv", "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=["op", "psnr"]) w.writeheader() w.writerows(rows) for r in rows: print(r) if __name__ == "__main__": main()跑通之后,你会得到一条质量-失真基线曲线。这条曲线的价值在于:以后任何新算法,把它处理后的结果替换进jpeg_roundtrip那一步,就能直接读出相对提升。资源库的意义就在这儿——大家用的是同一根尺子。
5.3 把"能跑通"变成"能复现"的三个习惯
第一个习惯是固定随机种子并写进文件名,噪声、裁剪、初始化全部这样。第二个习惯是把版本信息落盘,manifest 里加一个repo_version字段,每次更新资源库就撞一次版本号,论文里写清楚用的哪个版本。第三个习惯是保留中间产物,别只留最终图,处理链每一步的输出都存下来,出问题时能二分定位是哪一步引入的偏差。
我踩过最深的一个坑,是某次换机器后结果对不上,最后发现是不同版本的图像库在 JPEG 编码时用了不同的默认色度子采样模式,一个用 4:2:0,一个用 4:4:4。这个问题靠肉眼完全看不出来,只有 PSNR 差 0.3dB 告诉你出事了。从那以后我所有的 JPEG 相关实验都显式指定subsampling参数,绝不依赖默认值。
6. 关于这张图的来源争议,以及我现在的替代做法
6.1 为什么越来越多的地方不建议用它
这张图的原始素材来自一份商业刊物的插图,虽然被图像处理社区无授权使用了三十多年,但近些年随着版权意识和肖像权讨论的推进,越来越多的学术会议和期刊在投稿规范里明确建议不要使用它,一些课程也已经把它从作业里换掉了。这不是技术问题,是使用场景和合规意识的变化。作为从业者,我觉得没必要在这件事上纠结——它作为测试图的技术价值是真实的,但它不是唯一选择,也不用非用不可。
实际处理原则很简单:内部调试、教学演示这类不公开发表的场景,用它没问题;要投公开论文、要做对外发布的技术报告,就换成没有争议的素材。这个界限划清楚,两边都不耽误。
6.2 我自己现在用的三套替代素材
第一套是标准的合成靶图。自己用代码画:棋盘格检验块边界,同心圆检验频域响应,灰阶渐变条检验量化台阶,随机噪声块检验熵编码效率。这套图的好处是每一处细节你都知道它"应该"是什么样,出了问题可以直接反推是算法的哪一环。
import numpy as np from PIL import Image n = 512 y, x = np.mgrid[0:n, 0:n] checker = (((x // 32) + (y // 32)) % 2 * 255).astype(np.uint8) ring = ((np.sqrt((x - n/2)**2 + (y - n/2)**2) / 8).astype(int) % 2 * 255).astype(np.uint8) Image.fromarray(checker).save("靶图_棋盘格.png") Image.fromarray(ring).save("靶图_同心圆.png")第二套是纹理拼贴图。把平滑区、细密纹理、锐利边缘、规则条纹四类区域拼在一张图里,用不同比例拼几张不同版本,覆盖不同的特征组合。它的优势是可以按需配比,比如专门做去噪的可以多放平滑区,做压缩的可以多放高频纹理。
第三套是自然图像小集合。从授权明确、可商用的公开图片里挑 20 到 30 张,覆盖人像、建筑、风景、文档几个类别,分辨率统一到同一档。做量化结论的时候用这一套,样本量够,结论才有说服力,而且不会有来源上的麻烦。
6.3 换成新素材之后,怎么和旧结论对上
这是很多人不敢换的原因:怕新旧结果没法比较。实际上不用慌,方法是同步跑两份。在你的实验表格里保留一列旧素材的结果,再新增一列新集合的结果,两组趋势一致就说明算法本身的改进是真实的,只是绝对数值不同。如果趋势不一致,那才是真问题——说明算法对某类特征过拟合了,这比绝对值差异重要得多。
我个人的经验是,凡是只在那一张图上有效的改进,换到二十张图的集合上基本都会被打回原形。早点用多图集合做验证,能省掉大量后期返工。那张 512×512 的图到今天仍然有它的位置,做冒烟测试、做快速可视化、做教学演示,它依然是效率最高的选择。但要说它能支撑一篇论文的结论,那还是算了,一张图担不起这个责任。
最后再分享一个我在整理资源库时养成的习惯:每拿到一个"官方"版本的图和一段别人给的复现脚本,先用哈希把图钉死,再跑一遍脚本看输出对不对得上。对得上,说明你的环境和作者一致;对不上,先查图再查库版本,别一上来就怀疑自己的算法。十次里有七八次,问题就出在那张图身上。