做图像处理界面的时候,绕不开的一个东西就是 QImage。它不是 PySide6 里最花哨的类,却是几乎所有图像功能的地基。不管是打开图片预览、截取区域、逐像素处理,还是把一张图转换成界面能直接显示的格式,最后都要落到 QImage 这层来。这篇笔记我会从一个实际做界面的人的角度,把 QImage 的构造、像素读写、几何变换、绘制上屏以及和 numpy 结合的高效用法完整过一遍,适合刚好学到图像处理这一块、想把手里的图片数据真正跑起来的同学。
先说清楚一个常见误区:QImage 不是给你直接贴到窗口上用的,它是"离屏"的图片数据。真正的显示工具是 QPixmap;而 QImage 更像是一个存放在内存里、可以随意修改分析的图像对象。理解了这个分工,后面很多东西就顺了。
1. QImage在PySide6里的定位:一张与界面脱钩的图片
1.1 为什么图像处理要用 QImage 而不是 QPixmap
QPixmap 的设计目标是快,它是为屏幕绘制优化的,底层依赖 QPainter 和平台图形后端。在 Windows 上它可能走 DIB,在 Linux 上可能走 X11 或者 Wayland 的共享内存,在 macOS 上又会被 CoreGraphics 接管。这意味着 QPixmap 的内容未必能保证你拿到连续的像素数据,也不能保证像素格式和你期望的一模一样。
QImage 的设计目标则是"数据"。它不依赖任何窗口系统,自己管理一块连续的内存,里面是一个个像素点。你可以直接访问、修改、重新解释这块数据,甚至把内存地址交给 C 扩展来处理。所以一旦要做图像分析、图像变换、机器学习预处理这类事情,先让图片变成 QImage,然后在此基础上操作,是正确的路径。
我用一个类比来帮助理解:QPixmap 像拍立得照片,拍出来什么样就是什么样,适合拿给人看;QImage 像底片,你可以在暗房里随意调整,放大、裁剪、调色,最终要展示的时候再去冲印成照片(QImage 转 QPixmap)。
1.2 图像数据本质:它到底在内存里长什么样
QImage 的核心成员是 width、height、format 和一条指向像素数据的引用。format 决定了每个像素用多少位、怎么排列,比较常见的几个:
- Format_RGB888:每个像素 3 字节,顺序是 R、G、B,没有透明度。适合做通用图像数据的过渡格式。
- Format_ARGB32:每个像素 4 字节,顺序是蓝、绿、红、alpha(注意这里是小端序下的内存排列,实际用 QRgb 取值的时候是按 alpha、red、green、blue 的位组合来读的)。这是老项目里最常见的格式。
- Format_RGBA8888:每个像素 4 字节,内存顺序就是 R、G、B、A,和 numpy 里常见的 RGBA 布局一致。新代码里我更推荐这种,因为它的内存布局更直观,也方便和 OpenCV 之间互相倒腾。
- Format_Grayscale8:每个像素 1 字节,0 到 255。做灰度图时很省内存。
一张 1920x1080 的 Format_ARGB32 图片,占用的内存大约是 1920×1080×4,等于 8294400 字节,约为 8MB。有些同学会惊讶"一张图怎么占这么多内存",其实再加上缩放副本、QImage 和 QPixmap 两套拷贝,一个界面撑到几百 MB 一点也不奇怪。所以在做批量图像处理时,要时刻注意你持有的是数据的引用还是拷贝,这个我在后面性能那部分会详细讲。
2. 构造QImage的几条路:从文件、画布到numpy数组
2.1 从文件加载:代码只有一行,但容易忽略细节
从文件加载 QImage 是最常见的用法:
from PySide6.QtGui import QImage img = QImage("path/to/photo.jpg") if img.isNull(): print("加载失败,可能文件不存在或格式不支持")这里我需要强调两个点。
isNull() 一定要检查。因为构造失败时 QImage 不会抛异常,而是返回一个宽度为 0、高度为 0 的空对象。你不检查的话,后面一调用 width() 之类的就是无声的错误,界面上白茫茫一片却不知道问题出在哪。
第二个点是格式支持。PySide6 官方 wheels 通常会包含常用的 imageformats 插件,比如 jpg、png、bmp、webp,但如果你想加载 tiff、ico 等更偏门的格式,取决于你的环境有没有带上对应插件。以前我接到过一个需求,客户给的素材是 tiff 格式,本地开发环境能打开,部署到另一台机器上就打开了,后来查明就是 imageformats 插件没有带上。稳妥的做法是尽量在项目里固化一套输入格式,或者用 QImageReader 来显式地查询支持的格式列表,而不要假设什么都能读。
2.2 构造空白画布:Format选择直接影响后面的性能
有时候我们需要的是一张从零开始的图,比如做画板、生成验证码背景、创建蒙版。可以用:
img = QImage(800, 600, QImage.Format.Format_RGBA8888) img.fill(0xFFFFFFFF) # 白色不透明这里有个小坑:QImage 构造出来之后,像素数据并不保证是零。它只是分配了一块内存,里面可能是之前其他进程留下的残留数据。所以一定要调用 fill() 或者用 painter 把内容画上去之后再用,否则画布上会出现莫名其妙的噪点。
Format 的选择也要提前想好。如果只是做黑白蒙版,Format_Grayscale8 就足够了,比 ARGB32 省四倍内存,而且逐像素遍历时更快;如果要做带透明效果的图像合成,那就用 Format_RGBA8888 或者 Format_ARGB32。后面当你调用 convertToFormat() 去转换格式时,会多一次像素级拷贝,几百毫秒对一张大图来说很常见,所以一开始就选对格式能省很多事。
2.3 用 numpy 直接构造 QImage:图像处理接口的关键桥梁
PySide6 里没有直接从 numpy 数组构造 QImage 的构造函数,最常用的办法是共享内存:
import numpy as np from PySide6.QtGui import QImage height, width, channel = 480, 640, 3 data = np.zeros((height, width, channel), dtype=np.uint8) data[:, :, 0] = 200 # R data[:, :, 1] = 100 # G data[:, :, 2] = 50 # B img = QImage(data.data, width, height, data.strides[0], QImage.Format.Format_RGB888)这里构造函数里第 4 个参数是 bytesPerLine,很多资料直接写 width * channel,也就是 1920。但当你手里是一个连续的 C 数组时没问题,一旦 data 来自 numpy 的切片操作,比如 data[:, ::2],那么它的行字节数就不再是简单的 width × channel 了,必须用 strides[0]。如果传错,你会看到图像变成扭曲的斜纹,排查起来非常头疼。
还有一个很隐蔽的点:这样构造出来的 QImage 和 numpy 数组是共享同一块内存的,而且 QImage 默认不会去拷贝这份数据。这意味着如果 numpy 数组被释放了或者被重新 resize 了,QImage 就会变成悬空引用,继续使用会得到随机内容甚至崩溃。解决方法有两种:一是确保 numpy 数组的生命周期覆盖 QImage 的使用;二是立刻调用 img.copy() 做一次拷贝,让 QImage 独立持有数据。如果这张图要长期保留在界面里,我通常无脑 copy()。
原图可能带有 alpha 通道,所以如果你手里是 RGBA 的 numpy 数组,就对应 Format_RGBA8888。如果通道顺序是 BGRA,用 Format_BGR888 也能适配。关于通道顺序的问题,实操里非常常见,后面再细说。
3. 读写像素背后:从pixel()到内存级的bits()访问
3.1 直观但极慢的 pixel() 和 setPixel()
QImage 提供了最简单直观的像素读写接口:
color = img.pixel(100, 200) # 返回 QRgb,一个整数 qcolor = img.pixelColor(100, 200) # 返回 QColor img.setPixel(100, 200, QColor(255, 0, 0).rgb())这个方法在学习阶段用来验证想法非常方便。比如你想测试第 300 行第 400 列那个像素是不是偏红,写三行代码就跑通了。
但我要给你一个实际的数据:在 Python 里循环逐像素调用 pixelColor(),处理一张 1000×1000 的图,可能要好几秒。因为每一次调用都要经过 Python 到 C++ 的类型转换、方法调用、再返回一个 QColor 对象,而这个对象本身的构造又有额外开销。如果再加一层嵌套循环,性能会迅速变得不可接受。
结论就是:pixel() 和 setPixel() 适合调试小图、验证思路;真正上规模的处理,得走 bits()。
3.2 bits()、扫描线和 numpy 向量化:这才是高效的正确姿势
QImage 最底层的访问方式是 bits(),它返回一个 memoryview 对象,指向这块图像数据。借助它,我们可以把 QImage 包装成一个 numpy ndarray,然后所有操作都变成向量化运算,速度会快几个数量级:
from PySide6.QtGui import QImage import numpy as np def qimage_to_numpy(img): # 复制图片并转换到已知格式,避免出现不可预测的字节垫 padding img = img.convertToFormat(QImage.Format.Format_RGBA8888) width = img.width() height = img.height() # 注意:QImage 的扫描线可能有额外对齐,这里使用 bytesPerLine() ptr = img.bits() ptr.setsize(img.sizeInBytes()) arr = np.ndarray((height, width, 4), dtype=np.uint8, buffer=ptr, strides=(img.bytesPerLine(), 4, 1)) return arr.copy() # 拷贝一份,避免引用 dangling这里为什么用 strides=(img.bytesPerLine(), 4, 1) 而不是 (width*4, 4, 1)?因为 Qt 在某些格式和硬件加速组合下,每一行像素后面可能补了额外的字节,让每行起始地址对齐到 4 或 16 字节。如果直接用简单的 width × 4,对某些图片你会看到每行有个细微的错位,越往下越歪。
拿到 numpy 数组之后,所有处理都变得很舒服。比如把整张图变暗一半:
arr[:, :, 0] = arr[:, :, 0] // 2 arr[:, :, 1] = arr[:, :, 1] // 2 arr[:, :, 2] = arr[:, :, 2] // 2这里只谈访问方式的话,还可以用 ndarray 的切片、布尔索引、np.where 做局部处理,几乎能做到实时响应。当你把图转成 numpy 之后,处理逻辑就不再是 Qt 的边界,而是 numpy 的性能边界了。这也是我处理大图时的默认路径。
4. 缩放、裁剪与旋转:QImage的几何变换操作
4.1 缩放是要质量的,FastTransformation 和 SmoothTransformation差别不小
缩放一个 QImage 直接用 scaled():
scaled_img = img.scaled(320, 240, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)参数里有两点值得解释。KeepAspectRatio 表示保持宽高比,也就是说 640×480 的图缩到 320 宽时,高会自动变成 240,不会变形;如果你用 IgnoreAspectRatio,那图会被拉伸到指定的长宽,常见于做头像裁剪这种需要严格占满的场景。
第二个是 SmoothTransformation。这个选项表示使用平滑插值,缩放后的边缘不容易出现锯齿;FastTransformation 则是简单快速采样,适合做缩略图预览这种不要求质量的场合。一个容易忽略的细节是,SmoothTransformation 在缩放到很小尺寸时,计算量明显更大。如果你的列表项里要同时生成几十张缩略图,先用 FastTransformation 出一版交到界面上防止卡顿,等空闲了再平滑重算,是更稳的做法。
还有一个我踩过的坑:scaled() 缩放之后,如果原图是灰度图并且保持原有格式不变,出来的结果可能还是灰度图;但如果原图是 Format_RGB888,缩放结果在某些 Qt 版本里会变成 Format_ARGB32。如果你的代码依赖像素格式做判断,缩完之后最好确认一下 img.format(),必要时再 convertToFormat()。
4.2 copy()、mirrored() 和 transformed():裁剪、镜像和旋转的日常用法
裁剪本质上就是取子区域。QImage.copy(x, y, w, h) 会返回一个独立的新图像,和原来的数据不再共享内存。注意复制区域不能超出原图范围,否则越界部分会被填充为空数据。如果你需要的是保留原区域、在原图上画框而不是裁剪出子图,用 QPainter 往原图上画矩形就行,那是另一种思路。
镜像最直接:
mirrored = img.mirrored(horizontal=True, vertical=False)水平镜像相当于把图左右翻转,你拍的带字照片翻转后会变成镜像字,这是常见应用。
旋转的话,用 transformed() 配合 QTransform:
from PySide6.QtGui import QTransform transform = QTransform().rotate(90) rotated = img.transformed(transform, Qt.TransformationMode.SmoothTransformation)需要注意 rotation 方向是顺时针还是逆时针。QTransform().rotate(90) 是顺时针旋转 90 度;如果你要逆时针,对应 rotate(-90)。旋转之后图像的宽高会交换,如果你想把它放回原来的显示区域,记得处理布局。
4.3 一个容易被忽略的操作:rgbSwapped()
QImage 还有一个很贴心的内置方法 rgbSwapped(),它把红和蓝通道互换。为什么需要这个?因为很多相机和图像库默认输出 BGR 顺序,但你期望看到正常的 RGB。比如你从一个外部相机 SDK 拿到一张 BGR 的 buffer,转成 QImage 后显示出来发现蓝色和红色全反了,这时调用一下:
img = img.rgbSwapped()颜色就恢复正常了。这个方法比你自己遍历像素交换快得多,因为它内部是逐行按格式优化过的。我实际项目中经常因为摄像头 SDK 的通道顺序头大,这个方法救了我不少时间。
5. 让图片上屏:QImage与QPixmap、QPainter的配合
5.1 一次转换,后续重复使用:img 转 QPixmap 的正确姿势
QImage 本身不直接参与界面的 paintEvent 绘制也行,但 QLabel 和大多数控件倾向于接受 QPixmap,因为 QPixmap 是和设备相关的,底层已经尽可能做了针对当前显示硬件的优化。所以标准流程是:
from PySide6.QtGui import QPixmap pixmap = QPixmap.fromImage(img) label.setPixmap(pixmap)如果你用的是 QPainter 自主绘制,那可以不用转 QPixmap,直接 drawImage:
painter.drawImage(0, 0, img)这里有几个需要提醒的。QPixmap 是对 QImage 做了一次拷贝吗?不一定。Qt 内部有隐式共享机制,这个转换不会立刻复制所有像素,但它确实会经过一次平台相关处理。如果你的主循环里每帧都从 QImage 转 QPixmap,几百次下来会有明显 CPU 开销,所以对于不变的内容,尽量在 pEvent 之外只转一次缓存起来。
另外一个细节是高分屏。现在的笔记本很多是 2K 甚至更高分辨率,Qt 默认支持 devicePixelRatio。如果你把一张正好 100×100 的图片塞到 QLabel 里显示,而标签的实际物理像素尺寸在高分屏上是 200×200,图片会被放大,出现模糊。解决办法是设置图片设备的像素比:
pixmap.setDevicePixelRatio(devicePixelRatioF())或者在 QImage 阶段就把图缩放到需要的像素尺寸,让它在高分屏上清晰显示。很多初学者做出来的界面在自己机器上清晰,换到高分屏笔记本上变糊,根因往往就在这里。
5.2 用 QPainter 把 QImage 画到界面上:sourceRect 和 targetRect
当你需要把一张大图的一部分画到一个固定区域时,drawImage 有两个矩形参数很好用:
- targetRect:目标区域内要绘制的范围
- sourceRect:源图像里取哪一块
painter.drawImage(QRect(10, 10, 200, 150), img, QRect(50, 50, 200, 150))这个语法意思是:把源图里从 (50, 50) 开始、宽 200 高 150 的那块区域,画到目标矩形 (10, 10, 200, 150)。配合鼠标事件,可以实现图片查看器里的拖拽裁切预览效果。
我一般会在 paintEvent 里加一个绘制数量保护,比如先判断当前图片是否为空,再判断目标矩形和源矩形是否在合法范围内。否则一旦鼠标拖出的区域超过图片边界,绘制的内容就会失控。
5.3 给 QImage 加透明度:setOpacity 不是图像数据的一部分
QPainter 的透明度控制通常是在绘制时传入:
painter.setOpacity(0.5) painter.drawImage(0, 0, img)这样画出来的图片是半透明的,但 QImage 本身的像素数据没有被修改。如果你想让图片本身带上透明度信息,那就得操作 alpha 通道,比如把某个区域设置成全透明:
arr = qimage_to_numpy(img) arr[:, :, 3] = 0这两种方式的使用场景完全不同:前者适合做动画淡入淡出,后者适合做真正的图像蒙版抠图。写代码前先分清你要的是显示层的透明还是数据层的透明,能省掉不少弯路。
6. 一个能跑到界面上的图像处理小例子:灰度化与二值化
6.1 从理论到代码:灰度公式你为什么这么取值
在界面上做一个"一键变灰度"按钮是最典型的图像处理入门例子。灰度化的核心就是判断一个彩色像素的亮度。公式有很多种,Qt 官方计算灰度时采用的系数大致是:
gray = 0.299 R + 0.587 G + 0.114 B
这是给偏色系数做了加权,因为人眼对绿色最敏感、对蓝色最不敏感。如果你直接取三个通道的平均值,出来的灰度会偏亮,尤其蓝色占比较高的图效果会失真。
用 numpy 实现非常直观:
import numpy as np from PySide6.QtGui import QImage def image_to_grayscale(img: QImage) -> QImage: arr = qimage_to_numpy(img) # RGBA gray = (arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114).astype(np.uint8) h, w = gray.shape gray_qimage = QImage(gray.data, w, h, gray.strides[0], QImage.Format.Format_Grayscale8) return gray_qimage.copy()注意这里我用了 QImage.format.Format_Grayscale8,这样内存占用降低到原来的四分之一,后续如果要做阈值、形态学处理也更快。显示的时候 QPixmap 对灰度图也能正常处理,不需要再转换回 RGB。
6.2 阈值二值化与滑块调参:把处理参数暴露到界面上
二值化是灰度图的下游操作,把灰度分成黑和白。最简单的固定阈值模式是:
threshold = 128 binary = np.where(gray >= threshold, 255, 0).astype(np.uint8)但如果所有图片都用一个固定阈值,边界一复杂就会出现大片错误。我在工具界面里通常会加一个 QSlider 让使用者实时调阈值:
slider = QSlider(Qt.Orientation.Horizontal) slider.setRange(0, 255) slider.valueChanged.connect(on_threshold_change) def on_threshold_change(value): binary = np.where(gray >= value, 255, 0).astype(np.uint8) show_image(binary)滑块拖动时每秒会产生很多次 valueChanged,而大图做 numpy 运算也要时间,所以磨轮的时候要考虑性能。两个实用的优化:小图就直接算,大图先在低分辨率缩放图上预览效果,松开滑块条再对原图应用;或者使用 QTimer 做 100ms 聚合,只在最后一次变化时重算。
这一步实操下来,你会同时对 QImage、numpy 和 Qt 的交互回调有一个完整的串联理解。
6.3 保存处理结果:扩展名决定格式,参数控制质量
处理完的图片要落盘,QImage.save() 是最简单的:
ok = img.save("output.png", "PNG", 90) if not ok: print("保存失败")注意第二个参数其实是显式指定格式。如果省略,Qt 会根据文件扩展名推断,但是扩展名写错或者为空时很容易保存失败。另外质量参数对 PNG 影响不大,对 JPEG 影响很大,JPEG 质量 95 和 75 的文件大小能差几倍,同时肉眼看不出明显区别。如果你做的是批量导出任务,建议 PNG 无损、JPEG 质量控制在 85 左右,平衡体积和观感。
save() 还有一个细节:需要确保目标目录存在,否则返回 False。不要以为传了绝对路径就一定成功,目录权限和路径中文字符也会导致失败。正确姿势是保存前判断 os.path.exists(dirname)。
7. 性能、边界和常见翻车点:我实际用过之后的几个体会
7.1 为什么逐像素循环那么慢:一场 Python 和 C++ 之间的"翻译"
前面提到 pixel() 循环慢,这里展开说原因。Python 每调用一次 pixel(),实际上至少做三件事:构造参数、调用 C++ 绑定接口、构造返回值对象。如果你在一个 200 万像素的图上做两层循环,那就是 200 万次"翻译",每次翻译的固定开销大约在几微秒到几十微秒,乘起来自然不可接受。
而 numpy 向量化操作是把整个数组的运算交给 C 语言级别的循环执行,Python 层只发一条指令,然后等结果。所以同样做灰度化,从几秒降到几十毫秒并不稀奇。这条经验对 QImage 同样成立:凡是涉及逐像素、大面积、重复性运算,就先把图像转成 numpy 数组,把所有算术逻辑丢给 numpy 解决,最后只保留一个 QImage 转换回界面。
如果你确实需要在 Python 里严格按坐标访问一行数据,可以考虑把 QImage 先转成 numpy 之后再索引,而不是直接用 pixel。
7.2 我踩过的三个坑:copy、格式、内存归属
第一个坑是忽略了 QImage 和 numpy 数组之间的共享内存。用 QImage(data.data, ...) 构造出来后,如果你马上把 data 数组删除或者重新赋值,QImage 还是会指向那块已经释放的内存。结果就是图像时好时坏,有时候显示正常,有时候花屏,程序还可能在退出时崩溃。实际项目里排查这种问题很费时间,我现在统一规则:凡是进入 QImage 的数据都调用 img.copy(),虽然多一次拷贝,但换来稳定性。
第二个坑是格式没对齐。QImage 的 Format_ARGB32 在内存里是 B、G、R、A 的顺序,而 Format_RGBA8888 是 R、G、B、A。很多从摄像头一路透传过来的缓冲是 BGR 顺序的,如果你按 RGB888 构造 QImage,出来的颜色会和原画面红蓝颠倒。遇到这种情况,先在文档里确认"你这个 SDK 输出的通道顺序到底是什么",再决定用哪个 format。不要反复试错,查一次文档比瞎试十次都快。
第三个坑是 convertToFormat 的默认转换策略。某些情况下 Qt 会对图像做 alpha 预乘,输出结果和你期望的数值有细微差别。如果你只用它做格式统一,最好在转换后及时做一次像素级抽查,防止后续计算里残留意外。
7.3 关于字节对齐的一点点深入
在处理 QImage 时,bytesPerLine 这个字段比你想象的重要。Qt 为了保证每行起始地址对齐,可能会在行尾追加填充字节。比如一张 3 字节每像素的图,宽度是 100,按理说一行 300 字节,但 bytesPerLine 可能是 304 或者 320。此时如果你直接用 width*channel 当作 strides 去套 numpy,图像会整体歪斜,每隔几行会出现一条错位线。
这个问题在图片宽度恰好是某些值时不容易暴露,一旦宽度变了,歪斜立刻出现。所以我写了一个统一函数 qimage_to_numpy 和 numpy_to_qimage,每次都用 bytesPerLine 动态计算,不写死任何常量。这样跨平台、跨格式跑起来都稳定。
7.4 进阶方向:把处理放进 QGraphicsView 做缩放平移
如果只是单个 QLabel 显示图片,交互能力比较有限。当你需要做图像浏览器的连动放大、缩放下,可以考虑内置 QGraphicsView 和 QGraphicsScene,然后把 QImage 转变为一个 QPixmapItem 放进场景里。QGraphicsView 自带缩放、拖拽、惯性滑动,比自己手动处理鼠标事件省太多。当初我把显示控件从 QLabel 升级成 QGraphicsView 之后,图片浏览体验上了一个台阶。
前面说的 qimage_to_numpy 在这套模式下同样适用:你可以处理完 QImage 后把它转成 QPixmap 再塞回 item,更新内容时只需调用 item.setPixmap() 并触发一次 update,性能完全可控。
最后再分享一点自己的习惯:每当我写图像相关的功能,都会在项目里放一个 image_utils.py,专门封装这几个核心转换函数,包含 bytesPerLine 的处理、copy 逻辑和格式转换。后续所有模块都从这里面取函数用。这样既能保证格式一致,又能避免在十几个文件里重复踩同一类坑。如果你也走到图像处理这一步,我建议你像我一样尽早把 QImage 和 numpy 之间的桥梁函数固化下来,后面能省下很多时间。