现代Qt开发之图像处理进阶:像素操作与格式转换

现代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 屏上不糊

参考资源


到这里,图像处理的进阶知识就讲完了。像素格式的内存布局、scanLine 的行级操作、格式转换的入口策略、High DPI 的适配,这些是构建图片处理工具的基础。练习项目做出来,这一篇咱们就算交差了。

相关推荐
李永奉1 小时前
中科蓝讯SDK开发-BT893x 面条耳机连接vivo手机后连接APP端,最后手机端设置页面手动断开蓝牙连接,此时耳机端假断开
c语言·开发语言·嵌入式硬件·物联网·智能手机·电脑
秦老师Q2 小时前
php零基础入门到实战,从语法到框架,一篇搞定
开发语言·php
驭渊的小故事2 小时前
多线程02
java·开发语言·jvm
阿里嘎多学长2 小时前
2026-09-09 GitHub 热点项目精选
开发语言·程序员·github·代码托管
君顾13 小时前
本地折扣卡 CPS 平台搭建:从系统架构到落地实战
java·开发语言·折扣卡
liangshanbo12154 小时前
手写 Function.prototype.call 与 Function.prototype.apply 面试题满分答案
开发语言·javascript·原型模式
Zenova EdgeOS4 小时前
C++ 工业边缘 libcurl HTTP 客户端实战
开发语言·c++·网络协议·边缘计算
Jackson_GJH4 小时前
Qt 右键自定义菜单的实现
开发语言·c++·qt
CoderYanger5 小时前
前端基础——JavaScript(WebAPI)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展