现代Qt开发之图像处理进阶:像素操作与格式转换
兄弟们有群了:https://github.com/orgs/Awesome-Embedded-Learning-Studio/discussions/9
链接地址:https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt
静态网站一键直达:https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeQt/
前言:QImage 不只是"加载显示"
入门篇聊过 QImage 和 QPixmap 的分工:QImage 直接操作像素,QPixmap 为屏幕显示优化。把一张图加载出来、贴到控件上,确实用不着什么进阶知识。可批量处理像素(灰度化、反色、调亮度对比度)、跨像素格式转换、大图内存吃紧只装得下一部分------这几类活,入门篇一条路都没铺。这一篇咱们把像素这一层讲清楚。
Format 决定一切:QImage 的内存布局
QImage 的像素数据怎么存,由 QImage::Format 枚举说了算,咱们从最常用的几种摆起。Format_RGB32 每像素 4 字节(0xFFRRGGBB),alpha 固定 255,是 Qt 里最常用的显示格式。Format_ARGB32 同样每像素 4 字节(0xAARRGGBB),透明通道是真的。Format_Grayscale8 每像素只有 1 字节,灰度值 0 到 255,做图像处理时最省内存的就是它。Format_RGBX8888 也是 4 字节,Qt 6 推荐的格式,字节序安排更适合往 OpenGL 传纹理。另有一个容易想当然的点:ARGB32 这个名字写的是字节序,可内存里的实际存储顺序跟着平台字节序走,小端机器上并不是 A 打头,字节序的坑咱们留到踩坑一节单独说。
背这些格式名不是为了背。scanLine 返回的指针指向什么,完全由 Format 决定:RGB32 的 scanLine 每 4 字节一个像素,Grayscale8 的 scanLine 每 1 字节就是一个像素。咱们要是把 RGB32 的指针当灰度指针用,读出来的就是错位的数据,而编译器一个警告都不会给。
scanLine:高性能像素操作的正确姿势
QImage::scanLine(int row) 返回指定行第一个像素的 uchar*,直接拿指针操作内存是最快的像素访问方式。咱们把两种写法摆在一起,干同一件事------灰度转换:
cpp
#include <QImage>
#include <QElapsedTimer>
#include <cstdio>
// 写法一:setPixel 逐像素
static QImage grayBySetPixel(const QImage& src)
{
QImage out = src;
for (int y = 0; y < src.height(); ++y) {
for (int x = 0; x < src.width(); ++x) {
QRgb rgb = src.pixel(x, y);
int gray = (qRed(rgb) * 299 + qGreen(rgb) * 587 + qBlue(rgb) * 114) / 1000;
out.setPixel(x, y, qRgb(gray, gray, gray));
}
}
return out;
}
// 写法二:constScanLine 读、scanLine 写,直接操作行缓冲区
static QImage grayByScanLine(const QImage& source)
{
QImage result(source.size(), QImage::Format_Grayscale8);
for (int y = 0; y < source.height(); ++y) {
const uchar* srcLine = source.constScanLine(y); // 只读访问,不触发 detach
uchar* dstLine = result.scanLine(y); // 写入访问
for (int x = 0; x < source.width(); ++x) {
int offset = x * 4;
int blue = srcLine[offset]; // RGB32 在小端机器上的实际字节顺序:B G R 填充
int green = srcLine[offset + 1];
int red = srcLine[offset + 2];
int gray = (red * 299 + green * 587 + blue * 114) / 1000; // ITU-R BT.601 亮度公式
dstLine[x] = static_cast<uchar>(gray);
}
}
return result;
}
两种写法差多少、为什么差,咱们直接量。main 里除了计时,还埋了三个验证:两种结果逐点核对、隐式共享的 detach 行为、bytesPerLine 的实际字节数。
cpp
int main()
{
QImage src(4000, 3000, QImage::Format_RGB32);
for (int y = 0; y < src.height(); ++y)
for (int x = 0; x < src.width(); ++x)
src.setPixel(x, y, qRgb(x % 256, y % 256, (x + y) % 256)); // 造一张渐变测试图
QElapsedTimer t;
t.start();
QImage a = grayBySetPixel(src);
qint64 msSet = t.elapsed();
t.restart();
QImage b = grayByScanLine(src);
qint64 msScan = t.elapsed();
printf("setPixel : %lld ms\nscanLine : %lld ms\nspeedup : %.1fx\n",
(long long)msSet, (long long)msScan, (double)msSet / msScan);
// 核对:两种写法算出的灰度必须一致(每 500 像素抽一个点)
int mism = 0;
for (int y = 0; y < 3000; y += 500)
for (int x = 0; x < 4000; x += 500)
if (qRed(a.pixel(x, y)) != *(b.constScanLine(y) + x)) mism++;
printf("mismatch : %d\n", mism);
// detach 行为:constScanLine 不触发深拷贝,非 const 的 scanLine 触发
QImage shared = src;
qint64 k0 = src.cacheKey();
(void)shared.constScanLine(0);
qint64 k1 = src.cacheKey();
(void)shared.scanLine(0);
qint64 k2 = shared.cacheKey();
printf("cacheKey : source=%lld afterConst=%lld (same=%d) afterNonConst=%lld (same=%d)\n",
(long long)k0, (long long)k1, k0 == k1, (long long)k2, k2 == k0);
// 行缓冲区到底多少字节
QImage g(100, 100, QImage::Format_Grayscale8);
printf("bytesPerLine: RGB32(4000 wide)=%d Grayscale8(100 wide)=%d\n",
src.bytesPerLine(), g.bytesPerLine());
return 0;
}
编译运行:
bash
g++ -std=c++17 -O2 $(pkg-config --cflags --libs Qt6Gui) bench.cpp -o bench && ./bench
text
setPixel : 94 ms
scanLine : 22 ms
speedup : 4.3x
mismatch : 0
cacheKey : source=4306967296 afterConst=4306967296 (same=1) afterNonConst=17179869186 (same=0)
bytesPerLine: RGB32(4000 wide)=16000 Grayscale8(100 wide)=100
(咱们多跑几次、换台机器,毫秒数会漂几毫秒,4 倍出头这个关系是稳的。)
慢在哪,咱们拆开看不神秘。setPixel 和 pixel 每次调用都要过格式检查、边界检查,写路径还可能触发 detach;一张 4000x3000 的图,它们被调了 1200 万次。scanLine 整个循环只调 3000 次(每行一次),内层是纯内存读写,没有逐像素的函数调用开销。
这里笔者要修正一个流传很广的说法:"setPixel 比 scanLine 慢一到两个数量级"。至少在 Qt 6.11.1、GCC 16.1.1、-O2 这套组合上,同样的灰度转换量出来是 4 倍出头,不是 10 到 100 倍。绝对差距跟 Qt 版本、跟循环里每像素干的事强相关(要是再混进 QColor 转换或别的格式路径,会更难看),但方向不变:按行批量干活,行指针就是快。
后两行输出信息量也不小。咱们看 cacheKey 那行:QImage 是隐式共享的,QImage shared = src 只是引用计数加一,不拷贝数据。cacheKey() 是每块像素缓冲的身份证,深拷贝(detach)一发生就换号。输出里 afterConst 的 key 没变,说明 constScanLine 是只读访问、不惊动共享。afterNonConst 的 key 变了:非 const 的 scanLine 暗示要写,QImage 当场深拷贝一份 48MB。所以读用 constScanLine()、写才用 scanLine(),这不光是风格问题,是省一次整图拷贝的事。
mismatch : 0 是两种写法的交叉核对。scanLine 版本按 B、G、R、填充的顺序手工取字节,跟 qRed()/qGreen()/qBlue() 算出的灰度完全一致,这同时替咱们验证了小端机器上 RGB32 的内存顺序确实是 B 打头,字节序的事踩坑一节还会回来。最后一行是 bytesPerLine 的读数:一行 4000 个 RGB32 像素正好 16000 字节。这个数不一定等于"宽 × 每像素字节":宽 101 的 Grayscale8 实测 bytesPerLine 是 104,行尾垫了 3 个对齐字节。跨行计算时问 bytesPerLine,别自己乘。
convertToFormat:在管线入口统一格式
格式之间的转换归 QImage::convertToFormat 管。咱们需要知道的是它不便宜:不同格式的内存布局不同,转换必然新建一个 QImage 对象,内存和 CPU 的开销都是实打实的。
cpp
QImage source = QImage("photo.jpg").convertToFormat(QImage::Format_RGB32);
// source 保证是 RGB32,后续处理可以统一按这个格式假设
工程上省心的做法是咱们在处理管线入口一次转到位(通常转 RGB32 或 Grayscale8),后面所有处理函数都基于同一个格式假设来写,不用每个函数都分格式讨论。在十几个处理函数里各写一遍格式分支,既难维护又容易漏,入口统一一次搞定。
High DPI:devicePixelRatio 与绘制清晰度
高 DPI 显示器上,Qt 的坐标系走逻辑像素。一张 200x200 的 QImage 拿到 2x DPI 的屏上,会被自动拉伸到 400x400 物理像素显示,细节就糊了。咱们要在 QImage 上画文字或精细图形,创建阶段就得把 devicePixelRatio 算进去:
cpp
QImage image(size * devicePixelRatio, QImage::Format_RGB32);
image.setDevicePixelRatio(devicePixelRatio);
// 逻辑尺寸还是 size,物理分辨率加倍,画的内容在 2x DPI 屏上不糊
参考资源
- Qt 文档 · QImage:QImage 类完整参考
- Qt 文档 · QImage::Format:像素格式枚举
- Qt 文档 · QPixmap:QPixmap 类参考
到这里,图像处理的进阶知识就讲完了。像素格式的内存布局、scanLine 的行级操作、格式转换的入口策略、High DPI 的适配,这些是构建图片处理工具的基础。练习项目做出来,这一篇咱们就算交差了。