上一篇把粘连的水果分开了。这一篇离开 imgproc,进入第三阶段「图像读写与视频 I/O」------先把「读进来、写出去」这条链路走通。官方 samples 里三个例子对号入座:imagelist_creator 把一堆图片路径存进 yaml,imagelist_reader 再把它读出来逐张显示,imgcodecs_jpeg 则在内存里把同一张图按不同采样因子压缩、看失真。这一课还挖到一个彩蛋:OpenCV 4.x 的 lena.jpg 已经不是经典 Lena 了。
一、效果先行
先看 JPEG 采样因子。上排是 5 张一模一样的原图,下排是同一张图按 5 种色度采样因子压缩后的结果------从 444 到 411,圆环边缘的彩色晕染越来越明显:

再看图片列表。imagelist_creator 把 4 张图写进一个 yaml,imagelist_reader 读出来逐张灰度显示,按一下键翻一张:

两张图记住两件事:JPEG 靠「牺牲色度」换体积;FileStorage 靠「序列化」统一管理一堆图片路径。
二、FileStorage:把图片列表写进文件
批量处理几百上千张图时,最笨也最稳的办法是先把它们的路径存成一个清单,程序按清单逐张读。OpenCV 自带一套序列化框架 FileStorage,能把数据写成 YAML 或 XML。imagelist_creator 只做一件事------把命令行传进来的图片路径存进文件:
cpp
FileStorage fs(outputname, FileStorage::WRITE); // 打开文件,写模式
fs << "images" << "["; // 开一个叫 images 的序列
for (int i = 2; i < ac; i++)
fs << string(av[i]); // 逐条压入路径
fs << "]"; // 闭合序列
有个细节很贴心:它先 imread(outputname) 试探一下输出名,如果这个文件本身是一张图片(!m.empty()),立刻报错退出。为什么?防止用户手滑,把输出清单写成一张已有图片的名字,把原图覆盖掉。这个防覆盖检查,正是工程师该有的谨慎。
imagelist_reader 负责把清单读回来。核心是拿到顶层节点、确认它是序列,再逐条取出:
cpp
FileStorage fs(filename, FileStorage::READ);
FileNode n = fs.getFirstTopLevelNode(); // 顶层节点
if (n.type() != FileNode::SEQ) return false; // 必须是序列
for (FileNodeIterator it = n.begin(); it != n.end(); ++it)
l.push_back((string)*it); // 逐条取出路径
一个写、一个读,合起来就是完整的「图片清单」工作流。工业上做批量质检、数据集管理,第一步永远是这么个清单文件。
三、图片编解码四兄弟
读图写图有四个 API,两两对应,很多人分不清 imwrite 和 imencode 的区别:
| API | 方向 | 介质 | 典型场景 |
|---|---|---|---|
| imread | 文件 → Mat | 磁盘 | 加载图片 |
| imwrite | Mat → 文件 | 磁盘 | 保存图片 |
| imencode | Mat → 内存 buffer | 内存 | 压缩成字节流(发网络、写数据库) |
| imdecode | 内存 buffer → Mat | 内存 | 从字节流解码(收网络、读数据库) |
关键区别:imencode 和 imdecode 全程在内存里完成编解码,不落盘。相机抓到一帧、要压缩后通过网络发出去,走的就是 imencode;接收方拿到字节流,用 imdecode 还原成 Mat。imgcodecs_jpeg 这个例子,正是用这俩 API 演示内存编解码:
cpp
vector<int> param;
param.push_back(IMWRITE_JPEG_SAMPLING_FACTOR); // 指定参数名
param.push_back(config[i].sampling_factor); // 411/420/422/440/444
vector<uint8_t> jpeg;
imencode(".jpg", img, jpeg, param); // 压缩到内存
Mat lossy_img = imdecode(Mat(jpeg), -1); // 从内存解码还原
四、JPEG 采样因子:色度抽样
JPEG 压缩的核心洞察是:人眼对亮度敏感、对色度不敏感。所以它把图像拆成亮度分量和色度分量,亮度全采样,色度可以降采样------这就是「色度抽样」。采样因子控制色度被抽掉多少:
| 采样因子 | 比例 | 含义 |
|---|---|---|
| 444 | 4:4:4 | 色度不降采样,质量最高 |
| 422 | 4:2:2 | 水平色度减半,折中 |
| 420 | 4:2:0 | 水平垂直都减半,相机/网络默认 |
| 440 | 4:4:0 | 垂直色度减半,较少用 |
| 411 | 4:1:1 | 色度压得最狠,体积最小 |
官方测试图故意用一张粉紫色同心圆------高频的彩色边缘,最能暴露色度抽样的失真。从 444 到 411,圆环边缘的彩色晕染越来越重。而对灰度图,这参数几乎没影响,因为灰度图本来就没有色度。
五、实测
三个实例跑下来,两个是纯命令行、一个要弹窗。imagelist_creator 一行命令生成清单,4 张图的绝对路径整整齐齐写进 yaml;imgcodecs_jpeg 无参数直接吐出 800×320 的对比图。只有 imagelist_reader 需要 GUI 逐张显示,我用 Xvfb 虚拟屏加 xdotool 模拟按键翻页,4 张灰度图全部截了下来。
这里挖到一个彩蛋:OpenCV 4.x 的 lena.jpg 已经不是经典 Lena 了。那张 1972 年《花花公子》插图的扫描图,因为版权争议被 OpenCV 换掉,现在 reader 第一张显示的是个戴针织帽的人像。测试图也讲究版权------这大概是工业工程里最容易被忽略的一课。
六、踩坑记录
| 坑 | 现象 | 正确姿势 |
|---|---|---|
| 读图失败不报错 | imread 读不存在的文件返回空 Mat,不抛异常 | 用 empty() 判断,别依赖 try-catch |
| 清单结构不对 | reader 读不出、返回失败 | 顶层节点必须是序列(SEQ),用 << "[" ... "]" 写出来 |
| 相对路径失效 | 换目录跑 reader 找不到图 | 清单里存绝对路径 |
| 采样因子无效 | 灰度图看不出差别 | 色度抽样只对彩色图有意义 |
七、AI 与 LLM Wiki:这一课沉淀了什么
LLM Wiki 从 26 页涨到 27 页,这一课新增「图片读写与JPEG采样」概念页,沉淀了 FileStorage 序列化的写读范式、imread/imwrite/imencode/imdecode 四兄弟的区分,以及 JPEG 色度抽样的 5 档采样因子对照。进度表上 imagelist_creator、imagelist_reader、imgcodecs_jpeg 三个勾选完成,从 32 个变成 35 个,进度 36%。

这一课最值得进知识库的,是那个防覆盖设计------imread 先试探输出名、是图片就拒绝覆盖。它不是算法,却比算法更值钱,因为它体现的是「写代码时多想一步用户会怎么误操作」。把这类工程细节攒进知识库,比单纯记 API 有用得多。
写在最后
97 个实例,今天完成第 35 个,进度 36%,第三阶段「图像读写与视频 I/O」开了个头。这一站把「读进来、写出去」的链路铺平了:FileStorage 管清单,四兄弟管编解码,采样因子管体积。下一篇继续这条链路,上摄像头------从打开摄像头、调分辨率帧率,到写视频文件,把视频采集摸一遍。
本文示例代码均出自 OpenCV 官方 samples,遵循 Apache 2.0 协议。