用c++ 获取图像的长宽,配上opencv听起来就是两行代码的事——imread把图读进来,img.cols是宽、img.rows是高。可真把这行代码塞进批量处理脚本里,最先遇到的往往不是结果,而是一串空Mat、对调的宽高、以及被缩小了八倍的尺寸数字。我见过太多人在这件事上吃亏:代码没错,编译通过,运行不崩,读出来的尺寸却是错的,而且错得安安静静。这篇文章就从读取宽高这个最小的需求切入,把 OpenCV 解码链路、cols/rows的内存含义、只读尺寸不做全解码的写法、手机照片方向翻转、中文路径、16 位图、多页文件、批量扫描的性能账,全部拆开讲清楚。无论你刚配好 OpenCV 环境,还是已经在用图像处理做工程,都能从里面找到能直接用的代码和踩过的坑。
1. 一次 imread 里藏着三段路:宽高在哪一步被确定
1.1 从文件字节到 Mat:解码器、文件头与像素缓冲
很多人把imread当成一个黑盒,以为它"把图片变成矩阵"。实际上它内部至少走了三个阶段:找解码器、读文件头、解像素。第一阶段,OpenCV 根据文件开头的魔数(magic number)判断这是 PNG、JPEG 还是 WebP,然后去自己的注册表里找对应的解码器;如果编译时没有带上该格式的依赖库,这一步就直接失败,返回一个空的Mat。第二阶段,解码器调用内部的readHeader,把宽、高、通道数、位深这些元信息解析出来,填进图像对象的属性里。第三阶段才是真正的readData,把压缩数据解成一块行优先的内存缓冲区。
关键点在于:宽高是在第二阶段就已经确定的,它并不依赖第三阶段把像素解完。这也是为什么有些库能做到"只读尺寸不解码"。但 OpenCV 把这两个阶段封装在imread里,没有暴露一个公开的"只读文件头"接口,所以你调用imread拿cols/rows,本质上还是付出了全解码的代价。理解这条链路之后,后面很多问题就有了解释:为什么换个格式就读不出尺寸、为什么大图会触发像素上限报错、为什么某些环境下同一个文件在不同机器上结果不一样。
1.2 cols 是宽、rows 是高:这组命名背后的内存布局逻辑
cv::Mat的cols对应图像宽度(横向像素数),rows对应高度(纵向像素数),这个对应关系不是随手起的名字,而是由 OpenCV 的内存布局决定的。矩阵按行优先存储,一行像素在内存里连续排列,rows就是"有多少行",每行有cols个元素。所以一张 1920×1080 的三通道 8 位图,rows = 1080、cols = 1920,单行字节数为1920 × 3 = 5760,整块缓冲区约 6.2 MB。
新手最容易绕晕的地方是cv::Size。img.size()返回一个Size对象,而cv::Size的构造签名是Size(width, height),也就是Size(cols, rows)。于是代码里会出现这种写法:cv::Size sz = img.size(); int w = sz.width; int h = sz.height;,width拿到的是cols,height拿到的是rows。而cv::Rect又是Rect(x, y, width, height)。我见过有人在Rect里先填了高度再填宽度,结果裁出来一条竖着的窄边,排查了半天以为是数据问题。记住一句话:OpenCV 里凡是有 width/height 语义的结构,宽永远在前,只有Mat的成员变量把rows写在了cols前面,因为那是按行和列来命名的。
还有一个小细节值得提:img.total()返回的是元素总数,对二维图就是rows × cols,注意它不含通道数。想要总像素字节数,得自己算total() * elemSize(),或者用img.total() * img.elemSize()这句更稳妥的写法,elemSize()已经包含了通道数和位深。
1.3 channels()、dims、size() 各管一件事,不要混用
读尺寸时经常有人连带把channels()和dims也一起打印出来,然后被结果绕进去。这三个概念完全独立:dims是矩阵的维数,普通图像恒为 2,只有多维矩阵、直方图或者某些 DNN 张量才会是 3 或 4;channels()是每个像素有几个分量,灰度图是 1,BGR 是 3,带 Alpha 的 BGRA 是 4;size()只在dims == 2时才有意义,返回Size(cols, rows)。
channels()之所以容易和尺寸混淆,是因为同一张图用不同的imread标志读进来,通道数会变,但宽高不会变(除非带了方向标记或缩放标志)。比如一张 PNG 原图带 Alpha,你用IMREAD_COLOR读,通道数变成 3;用IMREAD_GRAYSCALE读,通道数变成 1;但cols和rows始终是原始宽高。只要你不去加IMREAD_REDUCED_*和方向相关的标志,宽高对同一文件是稳定的,这一点在写缓存、写数据库字段的时候可以放心依赖。
2. 读宽高的几种写法,以及它们各自的适用边界
2.1 最直接的一行:imread 之后取 cols/rows
先把最朴素的版本贴出来,它是后面所有变体的基础。这段代码我建议每个项目都留一份,作为"最小可复现单元",出问题时先跑它,能快速判断是环境问题还是业务代码问题:
#include <opencv2/opencv.hpp> #include <iostream> int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: imgsize <image_path>\n"; return 1; } // 第二参数决定解码方式:IMREAD_COLOR 强制三通道 8 位 cv::Mat img = cv::imread(argv[1], cv::IMREAD_COLOR); if (img.empty()) { std::cerr << "decode failed: " << argv[1] << "\n"; return 2; } std::cout << "width = " << img.cols << "\n" << "height = " << img.rows << "\n" << "channels = " << img.channels() << "\n" << "depth = " << img.depth() << "\n"; return 0; }这里有几个容易被忽略的取舍。第二个参数不传时默认是IMREAD_COLOR,也就是不管原图是不是灰度、是不是 16 位,一律转成 8 位三通道,这对只想拿宽高的场景其实无所谓,但如果你还要顺手做后续处理,就得想清楚。另一个点是img.empty()的判断,它同时覆盖了"文件不存在""路径错误""格式不支持""文件损坏"四种情况,看起来省事,实际上把排错信息全抹掉了,后面我会给一个能区分这几种情况的版本。
还有一个常被问的问题:imread返回的Mat是不是一定要clone()才能安全传出函数?不需要,Mat本身是引用计数的浅拷贝,只要不涉及 ROI 或者外部缓冲区包装,按值返回完全安全。但如果你把imread的结果包了一层cv::Mat的 ROI 再返回,返回的Mat引用了原来那块大内存,会导致整张大图无法释放,这个坑在批量处理里非常致命,后面单独讲。
2.2 用 IMREAD_REDUCED_* 缩放读取时,cols/rows 已经不是原始尺寸
有些人为了提速,会在读图时用上缩放标志,比如IMREAD_REDUCED_COLOR_8,想着"反正我只要尺寸,小图读得快"。逻辑听着通顺,结果拿到的cols是原图的八分之一。这些标志的取值和含义必须记清楚:
| 标志 | 枚举值 | 实际效果 | 读到的 cols/rows |
|---|---|---|---|
| IMREAD_GRAYSCALE | 0 | 转单通道 8 位 | 原始宽高 |
| IMREAD_COLOR | 1 | 转三通道 8 位 | 原始宽高 |
| IMREAD_ANYDEPTH | 2 | 保留原位深 | 原始宽高 |
| IMREAD_ANYCOLOR | 4 | 保留原通道数 | 原始宽高 |
| IMREAD_REDUCED_GRAYSCALE_2 | 16 | 灰度 + 缩小 1/2 | 原始的一半 |
| IMREAD_REDUCED_COLOR_2 | 17 | 彩色 + 缩小 1/2 | 原始的一半 |
| IMREAD_REDUCED_COLOR_4 | 33 | 彩色 + 缩小 1/4 | 原始的四分之一 |
| IMREAD_REDUCED_COLOR_8 | 65 | 彩色 + 缩小 1/8 | 原始的八分之一 |
| IMREAD_IGNORE_ORIENTATION | 128 | 忽略 EXIF 方向 | 与是否旋转有关 |
REDUCED系列的价值在于 JPEG:libjpeg 原生支持按 1/2、1/4、1/8 的整数倍做 DCT 域缩放,解码时直接跳过系数,速度提升是实打实的,一张 4000×3000 的 JPEG 用REDUCED_8读,耗时能降到全尺寸的几分之一。但 PNG 就不一定了,很多实现是解完全图再 resize,你可能既没省下时间,还多了一次缩放开销。所以只要你的目标是"原图尺寸",就别碰这些标志;只有在你明确需要一张缩略图、并且能接受尺寸变化时,才把它们用起来。
2.3 从内存字节读尺寸:imdecode 的完整写法
工程里图片数据常常不是躺在磁盘上,而是从网络、数据库、消息队列里来的一段std::vector<uint8_t>。这时候imread用不了,得换成imdecode。它的内部逻辑和imread一模一样,只是输入源从文件变成了内存块:
#include <opencv2/opencv.hpp> #include <fstream> #include <vector> bool sizeFromBytes(const std::vector<unsigned char>& buf, int& w, int& h) { if (buf.empty()) return false; // 用 vector 直接包裹内存,第二个参数 false 表示不拷贝, // 生命周期必须覆盖 imdecode 调用,这里 buf 是外部传入的,安全 cv::Mat raw(buf, false); cv::Mat img = cv::imdecode(raw, cv::IMREAD_COLOR); if (img.empty()) return false; w = img.cols; h = img.rows; return true; } bool sizeFromFile(const std::string& path, int& w, int& h) { std::ifstream ifs(path, std::ios::binary); if (!ifs) return false; std::vector<unsigned char> buf( (std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>()); return sizeFromBytes(buf, w, h); }这段代码在 Windows 上有额外的价值:当路径里含中文或特殊字符时,cv::imread走的是窄字符接口,底层fopen可能打不开文件,返回空Mat。改成"先用二进制流读进内存、再imdecode",就绕开了这个路径编码问题。前提是你的ifstream能打开该路径——如果连标准库都打不开,那就得用宽字符版本的_wfopen或者平台 API 先把文件读进来,再交给imdecode。这个组合拳我在跨平台项目里用了很多次,稳定性比直接imread高不少。
2.4 完全不解码:自己扫 PNG/JPEG/BMP 文件头的尺寸读取器
如果你的场景是"扫描十万张图,只统计尺寸分布",那用imread就有点浪费了——每张图都要把几十兆的像素解出来,再扔掉。这时候可以退一步,自己解析文件头。PNG 和 JPEG 的尺寸字段位置是固定的,代码量不大,几十行就能覆盖常见格式。
PNG 最简单:前 8 字节是固定签名,接下来 4 字节是 IHDR 块长度,再 4 字节是块类型IHDR,紧接着的 8 个字节就是宽和高,都是大端序。JPEG 复杂一点,需要从文件头开始逐段扫描,找到 SOF(Start of Frame)类型的段,里面的第 5、6 字节是高,第 7、8 字节是宽,同样是大端。
#include <cstring> #include <cstdint> #include <vector> #include <fstream> bool probeSize(const std::string& path, int& w, int& h) { std::ifstream ifs(path, std::ios::binary); if (!ifs) return false; std::vector<unsigned char> d((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>()); if (d.size() < 26) return false; // PNG:89 50 4E 47 0D 0A 1A 0A + 长度(4) + "IHDR" + 宽(4) + 高(4) static const unsigned char pngSig[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; if (std::memcmp(d.data(), pngSig, 8) == 0 && std::memcmp(d.data() + 12, "IHDR", 4) == 0) { w = (d[16] << 24) | (d[17] << 16) | (d[18] << 8) | d[19]; h = (d[20] << 24) | (d[21] << 16) | (d[22] << 8) | d[23]; return w > 0 && h > 0; } // JPEG:FF D8 开头,逐段扫描找 SOF0~SOF15(排除 C4/C8/CC) if (d.size() > 4 && d[0] == 0xFF && d[1] == 0xD8) { size_t i = 2; while (i + 9 < d.size()) { if (d[i] != 0xFF) { ++i; continue; } unsigned char m = d[i + 1]; if (m == 0xFF) { ++i; continue; } // 填充字节 if (m == 0xD8 || m == 0x01 || (m >= 0xD0 && m <= 0xD7)) { i += 2; continue; // 无长度字段 } size_t len = (d[i + 2] << 8) | d[i + 3]; if (len < 2) return false; bool isSOF = (m >= 0xC0 && m <= 0xCF) && m != 0xC4 && m != 0xC8 && m != 0xCC; if (isSOF) { h = (d[i + 5] << 8) | d[i + 6]; w = (d[i + 7] << 8) | d[i + 8]; return w > 0 && h > 0; } i += 2 + len; // 跳过该段 } } // BMP:偏移 18 是宽,22 是高,小端有符号(高为负表示自上而下) if (d.size() > 26 && d[0] == 'B' && d[1] == 'M') { int32_t bw = d[18] | (d[19] << 8) | (d[20] << 16) | (d[21] << 24); int32_t bh = d[22] | (d[23] << 8) | (d[24] << 16) | (d[25] << 24); if (bh < 0) bh = -bh; w = bw; h = bh; return w > 0 && h > 0; } return false; // 其他格式交给 OpenCV }这个解析器的适用范围要心里有数:它只认 PNG、JPEG、BMP 三种头,WebP、TIFF、HEIC 一律返回 false,这时候再回退到imdecode兜底。还有一点,JPEG 的 EXIF 方向信息它不会处理,拿到的永远是文件里存储的原始宽高,这对统计场景反而是好事。我第一次写这个函数时踩过一个坑:把 SOF 的判断写成了m >= 0xC0 && m <= 0xCF,忘了排除0xC4(DHT,霍夫曼表)和0xCC(DAC,算术编码条件),结果某些 JPEG 在 DHT 段里读到了垃圾数据,宽高变成几万的离谱值。排查时用十六进制编辑器逐字节对照才找出来,这个细节必须记住。
3. 宽高读错的排查链路:从空 Mat 到方向翻转
3.1 第一现场:empty() 为真时先查路径与编解码器
img.empty()返回 true,这是最常见的失败形态,也是最没有信息量的一种。我的排查顺序固定为四步,基本能覆盖九成情况。
第一步,确认文件真实存在。别看代码,去终端ls或者资源管理器里点一下。看似废话,但路径拼接少一个斜杠、工作目录和想象的不一样、软链接失效,这些是最常见的起因。第二步,确认路径里有没有非 ASCII 字符或者空格,Windows 下尤其要注意,前面说的imdecode方案就是为这个准备的。第三步,确认 OpenCV 编译时带没带对应格式的解码器。用cv::haveImageReader()可以直接问它:
std::string path = "sample.webp"; if (!cv::haveImageReader(path)) { // 可能是文件不存在,也可能是当前构建不支持该格式 std::cerr << "no decoder or file missing: " << path << "\n"; }这个函数内部会尝试找解码器,返回 false 时说明"要么读不到文件,要么没有匹配的编解码器"。自己在源码环境下编译过 OpenCV 的人应该有体会,WITH_JPEG、WITH_PNG、WITH_TIFF、WITH_WEBP这些开关关掉任何一个,对应格式就直接读不了。第四步,看文件本身有没有损坏,拿系统自带的看图工具打开试试,或者换个已知正常的文件对照。
3.2 手机照片宽高对调:EXIF 方向标记与 IGNORE_ORIENTATION
这个问题非常隐蔽。手机竖着拍的照片,传感器出来的像素其实是横的,方向信息写在 EXIF 的 Orientation 标签里(值为 6 或 8 表示需要旋转 90 度)。看图软件会读这个标签并自动旋转,所以你在相册里看到的是竖图。而 OpenCV 从 3.4.1 版本开始,imread默认也会应用这个旋转,于是你拿到的cols和rows是被交换过的。
结果就是:同一张照片,用系统看图软件看到的是 3024×4032,用 OpenCV 读出来也是 3024×4032,但如果你在别的语言或者旧版本 OpenCV 里读到 4032×3024,两边一对不上,就会怀疑人生。想拿到不旋转的原始尺寸,把IMREAD_IGNORE_ORIENTATION(值 128)显式加上:
// 方式一:忽略方向,拿存储尺寸 cv::Mat raw = cv::imread(path, cv::IMREAD_COLOR | cv::IMREAD_IGNORE_ORIENTATION); // 方式二:保留方向,拿"人眼看到的"尺寸 cv::Mat shown = cv::imread(path, cv::IMREAD_COLOR);这里有个我实测出来的经验:不要指望IMREAD_UNCHANGED(-1)一定不旋转。不同版本对-1和方向标志的组合处理并不完全一致,想稳就拿IMREAD_COLOR | IMREAD_IGNORE_ORIENTATION这种显式组合,别赌默认行为。另外,如果你的业务是"按显示效果排版",就该用旋转后的尺寸;如果是"和相机原始数据对齐",就该用未旋转的。先想清楚你要哪个,再决定加不加这个标志,别等到下游报错才发现。
3.3 位深与通道:16 位 PNG、灰度图、带 Alpha 的尺寸陷阱
位深不影响宽高,但会影响你"以为读到的东西"。一张 16 位灰度 PNG,用IMREAD_COLOR读,OpenCV 会把它转成 8 位三通道,cols/rows没问题,但如果你后续要按位深做归一化,信息已经丢了。用IMREAD_UNCHANGED或IMREAD_ANYDEPTH才能保留原始位深,此时img.depth()是CV_16U,elemSize()是 2 而不是 1,内存占用翻倍。
带 Alpha 的 PNG 也一样:默认读成 3 通道,IMREAD_UNCHANGED读成 4 通道。这两种模式下cols/rows完全一致,所以只读尺寸的时候随便用哪个都行,但如果你要用同一份数据做后续处理,就得前后一致。我在一个项目里遇到过这样的问题:读取阶段用默认模式,写入阶段用IMREAD_UNCHANGED读模板图,结果模板的透明通道被当成第四通道数据参与运算,输出的颜色全偏了。根因不是尺寸,而是通道数在两条路径上不一致。
3.4 多页文件、动图与视频首帧:只读到"第一页"
TIFF 支持多页,GIF 支持多帧,OpenCV 的imread默认只读第一页/第一帧。如果你的 TIFF 里第一页是缩略图(有些扫描设备会这么干),你读到的尺寸就是缩略图的尺寸,和实际页面差着数量级。要读多页得用cv::imreadmulti:
std::vector<cv::Mat> pages; bool ok = cv::imreadmulti("scan.tiff", pages, cv::IMREAD_COLOR); if (ok && !pages.empty()) { // 每页尺寸可能不同,逐页取 for (size_t i = 0; i < pages.size(); ++i) { std::cout << i << ": " << pages[i].cols << " x " << pages[i].rows << "\n"; } }视频文件用imread是读不了的,得用cv::VideoCapture打开再取首帧,cap.get(CAP_PROP_FRAME_WIDTH)和CAP_PROP_FRAME_HEIGHT能直接拿到分辨率,不用真的解码一帧。注意这两个属性要在cap.open()成功之后读,有些容器格式要等到第一帧解码完才有准确值,稳妥的做法是先read一帧,读到了再取值。
3.5 ROI 与浅拷贝:Mat 的 size 会跟着头信息走
这条严格说不算"读错",但确实是很多人对尺寸产生误判的原因。cv::Mat的拷贝是浅拷贝,共享同一块像素数据,但头信息(包括 rows、cols、step)是各自独立的。所以:
cv::Mat img = cv::imread(path); cv::Mat roi = img(cv::Rect(100, 100, 300, 200)); std::cout << roi.cols << " x " << roi.rows; // 300 x 200,不是原图尺寸用 ROI 之后,roi.cols是 ROI 的宽,roi.rows是 ROI 的高,roi.data却指向原图缓冲区中间某个位置,roi.step仍然是原图一行的字节数。这时候如果你把roi传给一个按"数据连续"假设写的函数(比如某些自己封装的序列化逻辑),就会读串行。判断连续性用roi.isContinuous(),必要时roi.clone()一下。批量处理里还有个内存层面的坑:把一堆 ROI 存进vector<Mat>长期持有,每一小块都拽着整张大图不释放,内存曲线一路往上飙——这种问题看代码看不出来,得用内存分析工具才能定位。
4. 批量统计图片尺寸时,怎么把开销压下去
4.1 先算一笔账:读一张 4K 图的真实代价
在动手优化之前,先把账算清楚。一张 3840×2160 的 8 位三通道图,解码后的像素缓冲区是3840 × 2160 × 3 ≈ 24.9 MB。如果你只是想知道它是 4K,却为此分配了 25 MB 内存、走了一遍 JPEG 反量化与 IDCT、再做了一次色彩空间转换,那这 25 MB 和这段时间基本是白花的。
不同分辨率、不同位深的开销差异很大,我整理了一张对照表,方便你估算批量任务的资源需求:
| 分辨率 | 8 位灰度 | 8 位三通道 | 16 位三通道 | 说明 |
|---|---|---|---|---|
| 1280×720 | 0.9 MB | 2.6 MB | 5.3 MB | 常见网页图 |
| 1920×1080 | 2.0 MB | 5.9 MB | 11.9 MB | 常见截图 |
| 3840×2160 | 7.9 MB | 24.9 MB | 49.8 MB | 4K 素材 |
| 6000×4000 | 22.9 MB | 68.7 MB | 137.4 MB | 单反原图 |
看到这张表就该明白:一万张单反原图全解码一遍,光是瞬时内存峰值就能把普通机器压垮,更别说耗时。所以批量场景下,"要不要全解码"是个必须做的决策,而不是默认选项。我的判断标准很简单:只统计尺寸就用文件头解析,需要做内容分析才全解码。中间还有一档,用REDUCED_8读一张缩略图做初步筛选(比如判断是不是纯色图、是不是损坏),这也比全解码划算。
4.2 目录扫描 + 只读文件头的混合策略
实际工程里我的做法是三层回退:先按扩展名分流,PNG/JPEG/BMP 走自己写的probeSize;其他格式用imdecode全解码兜底;两次都失败就记录下来交给人工核查。目录遍历用std::filesystem,C++17 起标准库就自带了,不需要额外的依赖:
#include <filesystem> #include <vector> #include <string> namespace fs = std::filesystem; struct Entry { std::string path; int w = 0; int h = 0; bool ok = false; }; std::vector<Entry> scanDir(const std::string& root) { std::vector<Entry> out; for (const auto& p : fs::recursive_directory_iterator(root)) { if (!p.is_regular_file()) continue; std::string ext = p.path().extension().string(); for (auto& c : ext) c = static_cast<char>(std::tolower(c)); if (ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".bmp") continue; Entry e; e.path = p.path().string(); e.ok = probeSize(e.path, e.w, e.h); out.push_back(e); } return out; }多线程方面,probeSize里都是纯文件读取和字节解析,没有共享状态,可以放心并行。用线程池把目录切成若干块处理,注意两点:一是输出容器要么加锁要么每个线程写自己的局部容器最后合并,别让多个线程同时push_back同一个vector;二是别开太多线程,磁盘 IO 密集型的任务开到核心数就够,开几十个线程只会让磁头来回跳。如果走的是imdecode兜底路径,OpenCV 内部某些编解码器会有自己的全局状态,虽然常规用法下没问题,但在极端并发下我倾向于给每个线程独立的缓冲区,不做跨线程复用。
4.3 把 OpenCV 接入构建系统的关键几行
代码写对了,编译链接不过也是白搭。CMake 是现在最通用的方式,关键几行如下:
cmake_minimum_required(VERSION 3.16) project(imgsize LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED COMPONENTS core imgcodecs imgproc) add_executable(imgsize main.cpp) target_include_directories(imgsize PRIVATE ${OpenCV_INCLUDE_DIRS}) target_link_libraries(imgsize PRIVATE ${OpenCV_LIBS})三个细节值得说。第一,find_package里显式写COMPONENTS,只引你真正用到的模块,imgcodecs是读图必须的,很多人只写core然后链接失败,就是因为漏了它。第二,CMAKE_CXX_STANDARD 17是std::filesystem的前提,老编译器上还得手动链接stdc++fs。第三,如果find_package找不到,通常是OpenCV_DIR没设置,把它指向 OpenCV 安装目录下的build或者lib/cmake/opencv4即可。Windows 上还有个常见问题:Debug 配置下链接了 Release 版的库,运行时崩溃,检查OpenCV_LIBS里带不带d后缀,或者干脆统一用一种配置构建。
4.4 一个可以直接抄的 ImageSize 封装
把前面的东西收拢,我给一个实际项目里在用的轻量封装。它的设计目标是:调用方不用关心底层用的是imread还是文件头解析,返回结构里带状态码,能把"文件不存在""格式不支持""解码失败""尺寸为零"这几种情况分开:
#include <opencv2/opencv.hpp> #include <string> enum class SizeStatus { Ok, FileMissing, NoDecoder, DecodeFailed, InvalidSize }; struct ImageSize { int width = 0; int height = 0; int channels = 0; SizeStatus status = SizeStatus::DecodeFailed; bool ok() const { return status == SizeStatus::Ok; } }; ImageSize querySize(const std::string& path, bool fastProbe = true) { ImageSize s; // 1) 快速路径:自己解析文件头,不解码像素 if (fastProbe && probeSize(path, s.width, s.height)) { s.status = SizeStatus::Ok; return s; } // 2) 兜底路径:交给 OpenCV 解码 if (!cv::haveImageReader(path)) { std::error_code ec; s.status = std::filesystem::exists(path, ec) ? SizeStatus::NoDecoder : SizeStatus::FileMissing; return s; } cv::Mat img = cv::imread(path, cv::IMREAD_COLOR); if (img.empty()) { s.status = SizeStatus::DecodeFailed; return s; } s.width = img.cols; s.height = img.rows; s.channels = img.channels(); s.status = (s.width > 0 && s.height > 0) ? SizeStatus::Ok : SizeStatus::InvalidSize; return s; }这个封装有两个刻意的设计。一是fastProbe默认开启,统计场景下能省掉绝大部分解码开销,需要精确通道数时传 false 走完整路径。二是状态码区分了"文件不存在"和"没有解码器",这两种情况在上层是完全不同的处理逻辑:前者是数据问题,后者是环境问题,混在一起会让人排查方向跑偏。我在一个线上任务里就吃过这个亏,日志里全是"读取失败",查了半天以为是磁盘挂了,结果是某批 WebP 文件在裁剪过的运行环境里没有解码器。
5. 交叉验证:怎么确认读到的宽高是真的
5.1 用系统工具和文件大小做旁证
尺寸读出来之后,怎么确认它是对的?我一般用两个独立手段交叉验证。第一个是系统自带的文件信息工具,Linux 和 macOS 上file命令会直接打印图像的宽高,Windows 上右键属性里的详细信息也有;第二个是拿文件大小反推,虽然精度不高,但能识别数量级错误。比如一张 4000×3000 的 JPEG 只有 8 KB,那基本可以断定它是一张纯色图或者损坏文件,尺寸对不对已经不重要了。
还有一种验证方式是像素总数一致性检查。如果你在同一个流程里既读了尺寸又做了数组操作(比如把图像数据塞进一维数组),那么w * h * channels必须等于数组长度,不等就说明中间某个环节动了尺寸。这个检查我建议直接写成断言留在代码里,比事后排查便宜得多。
5.2 写一个自检函数,把"读失败"和"读到 0"分开
最后一件事,也是很多人忽略的:返回 0 和返回失败是两回事。imread失败时返回空Mat,此时cols和rows都是 0,如果你不判断empty()就直接用,会得到一个"尺寸为 0×0 的合法图像",然后在下游某处除零崩溃,堆栈还指不到这里。我习惯在读取层就把这个界限划清楚:
bool readSizeChecked(const std::string& path, int& w, int& h, std::string& err) { cv::Mat img = cv::imread(path, cv::IMREAD_COLOR); if (img.empty()) { err = "imread returned empty"; return false; // 读失败,不是尺寸为零 } if (img.cols <= 0 || img.rows <= 0) { err = "invalid dimension"; return false; // 理论上不该发生,兜底 } w = img.cols; h = img.rows; return true; }调用方拿到 false 就该走异常分支,不要试图用宽高为 0 的对象继续往下算。这个习惯看着啰嗦,但在批量任务里能省掉大量"为什么输出全是空文件"的排查时间。另外提醒一句,imread对大图有像素总数上限保护,默认是一组十亿级别的阈值,超过会直接解码失败并返回空Mat,日志里可能出现"image is too large"之类的提示。真遇到超大图(比如航拍拼接图、病理切片),需要按需调整这个上限或者改用分块读取,别硬刚。
最后分享一个我在实际项目里养成的习惯:任何涉及图像尺寸的模块,我都会在单元测试里放三张"刁钻"的样本——一张带 EXIF 旋转的手机照片、一张带 Alpha 的 PNG、一张 16 位灰度图,断言它们读出来的宽高与预期完全一致。这三张图帮我拦下过至少两次因为升级 OpenCV 版本导致的行为变化,比读文档管用得多。尺寸这东西看着简单,但它是所有后续计算的基准,基准错了,后面写得再漂亮都是白费。